diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9c2472f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Windows release +on: + release: + types: [published] + workflow_dispatch: +permissions: + contents: write +jobs: + windows: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install setup packager + run: python -m pip install pyinstaller==6.19.0 numpy==2.2.6 Pillow==11.3.0 + - name: Build source-only release + shell: pwsh + run: ./Build-Release.ps1 + - uses: actions/upload-artifact@v4 + with: + name: skate3rust-windows-x64 + path: | + target/skate3rust-windows-x64.zip + target/skate3rust-windows-x64.zip.sha256 + - name: Attach to release + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$env:RELEASE_TAG" target/skate3rust-windows-x64.zip target/skate3rust-windows-x64.zip.sha256 --clobber diff --git a/.gitignore b/.gitignore index c53ee6d4..128639d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /bin/ /logs/ /settings/ +__pycache__/ +*.pyc /assets/private/ /maps/private/ /maps/*.skate diff --git a/Build-Release.ps1 b/Build-Release.ps1 new file mode 100644 index 00000000..c2937975 --- /dev/null +++ b/Build-Release.ps1 @@ -0,0 +1,55 @@ +param() +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { + $packagePython = Join-Path $PSScriptRoot 'target/package-venv/Scripts/python.exe' + if (-not (Test-Path -LiteralPath $packagePython)) { + & python -m venv target/package-venv + if ($LASTEXITCODE -ne 0) { throw 'Could not create packaging environment' } + } + & $packagePython -m pip install -r tools/requirements-setup.txt + if ($LASTEXITCODE -ne 0) { throw 'Could not install packaging dependencies' } + $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' + & cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --no-default-features + if ($LASTEXITCODE -ne 0) { throw 'Release compilation failed' } + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $stage = Join-Path $PSScriptRoot "target/release-packages/$stamp/skate3rust-windows-x64" + New-Item -ItemType Directory -Path "$stage/support" -Force | Out-Null + Copy-Item -LiteralPath target/x86_64-pc-windows-msvc/release/skate3rust.exe -Destination "$stage/skate3rust.exe" + $sourceStage = Join-Path $stage '../setup-source' + $toolsRoot = Join-Path $PSScriptRoot 'tools' + foreach ($source in Get-ChildItem -LiteralPath $toolsRoot -File -Recurse) { + if ($source.FullName -match '[\\/]__pycache__[\\/]') { continue } + if ($source.Extension -notin '.py','.json','.txt','.md','.toml' -and $source.Name -ne 'LICENSE') { continue } + $relative = [IO.Path]::GetRelativePath($toolsRoot, $source.FullName) + $destination = Join-Path "$sourceStage/tools" $relative + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Copy-Item -LiteralPath $source.FullName -Destination $destination + } + & $packagePython -m PyInstaller --noconfirm --clean --onefile --name skate3setup ` + --icon "$PSScriptRoot/docs/images/skating-crab.ico" --paths $PSScriptRoot ` + --hidden-import numpy --hidden-import PIL.Image --hidden-import tkinter ` + --copy-metadata numpy --copy-metadata Pillow --copy-metadata PyInstaller ` + --add-data "$sourceStage/tools;tools" --add-data "$PSScriptRoot/docs/images/skating-crab.ico;docs/images" ` + --distpath "$stage/support" --workpath target/setup-build/work --specpath target/setup-build tools/setup.py + if ($LASTEXITCODE -ne 0) { throw 'Setup packaging failed' } + Copy-Item -LiteralPath README.md,THIRD_PARTY_NOTICES.md -Destination $stage + New-Item -ItemType Directory -Path "$stage/docs/images" -Force | Out-Null + Copy-Item -LiteralPath docs/images/skating-crab.png -Destination "$stage/docs/images/skating-crab.png" + Copy-Item -LiteralPath docs/installation.md -Destination "$stage/docs/installation.md" + New-Item -ItemType Directory -Path "$stage/licenses" -Force | Out-Null + Copy-Item -LiteralPath tools/vendor/utt/LICENSE -Destination "$stage/licenses/UTT.txt" + Copy-Item -LiteralPath tools/vendor/university/LICENSE-PROJECT.md -Destination "$stage/licenses/CustomEngineLayer.txt" + Copy-Item -LiteralPath vendor/bevy_pbr/LICENSE-MIT -Destination "$stage/licenses/Bevy-MIT.txt" + Copy-Item -LiteralPath vendor/bevy_pbr/LICENSE-APACHE -Destination "$stage/licenses/Bevy-APACHE.txt" + $pythonBase = (& $packagePython -c 'import sys; print(sys.base_prefix)').Trim() + Copy-Item -LiteralPath "$pythonBase/LICENSE.txt" -Destination "$stage/licenses/Python.txt" + foreach ($license in Get-ChildItem -LiteralPath "$pythonBase/tcl" -Filter license.terms -Recurse -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $license.FullName -Destination "$stage/licenses/$($license.Directory.Name).txt" + } + $zip = Join-Path $PSScriptRoot 'target/skate3rust-windows-x64.zip' + Compress-Archive -LiteralPath $stage -DestinationPath $zip -Force + (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLower() + ' skate3rust-windows-x64.zip' | + Set-Content -LiteralPath "$zip.sha256" -Encoding ascii + Write-Host "Release package: $zip" +} finally { Pop-Location } diff --git a/Build.ps1 b/Build.ps1 index 4bd1c926..00491f1f 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -7,7 +7,7 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Build failed; see the compiler output above.' } } $debugDirectory = Join-Path $TargetDirectory 'debug' - $executable = Join-Path $debugDirectory 'skate-game.exe' + $executable = Join-Path $debugDirectory 'skate3rust.exe' if (-not (Test-Path -LiteralPath $executable)) { throw "Missing executable: $executable" } $readobj = Join-Path $env:ProgramFiles 'LLVM/bin/llvm-readobj.exe' if (-not (Test-Path -LiteralPath $readobj)) { throw 'LLVM llvm-readobj is required to stage exact runtime DLL dependencies.' } @@ -49,5 +49,5 @@ try { } } $staged | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $binDirectory 'manifest.json') -Encoding UTF8 - Write-Host "Ready: $binDirectory/skate-game.exe" + Write-Host "Ready: $binDirectory/skate3rust.exe" } finally { Pop-Location } diff --git a/Cargo.toml b/Cargo.toml index 1373cf46..36cacc18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" publish = false [workspace.dependencies] -bevy = { version = "=0.18.1", features = ["dynamic_linking"] } +bevy = { version = "=0.18.1" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" skate-core = { path = "crates/skate-core" } diff --git a/Launch.ps1 b/Launch.ps1 index 4bddef50..ad0080e0 100644 --- a/Launch.ps1 +++ b/Launch.ps1 @@ -2,7 +2,7 @@ param([string]$Map) $ErrorActionPreference = 'Stop' Push-Location $PSScriptRoot try { - $executable = Join-Path $PSScriptRoot 'bin/skate-game.exe' + $executable = Join-Path $PSScriptRoot 'bin/skate3rust.exe' if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' } New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot 'logs') -Force | Out-Null $log = Join-Path $PSScriptRoot ('logs/game-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') diff --git a/README.md b/README.md index 166486a2..b5df693b 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,30 @@ A Rust and Bevy skating project built from Skate 3 reverse-engineering research. Includes skating, tricks, grinds, offboard movement, difficulty settings and `.skate` map support. Gameplay parity is still a work in progress. -## Build and run +## Play + +Download the Windows release ZIP, extract it and run `skate3rust.exe`. +Select your own Skate 3 Xbox 360 ISO when prompted. Setup prepares the skater, +animations and disc maps locally, then starts University. The first setup needs +internet access for conversion tools and can take a while. + +Use an XInput controller to play. Escape opens graphics, difficulty and map +settings. Loading another map restarts the game session. + +**Skate 3 assets are not included.** Your converted files stay in +`%LOCALAPPDATA%/Skate3RustEngine`. + +## Build Requires Windows, Rust with the MSVC toolchain, and LLVM installed in its default location. Run `BUILD.bat` to build, then `PLAY.bat` to launch the test world. Use `PLAY-MAP.bat` to select a map. An XInput controller is required for gameplay; Escape opens difficulty and graphics settings. -**Skate 3 assets are not included.** Running the game requires a separately -prepared local asset set in `assets/private/`, including its `game.json` -manifest, character, animation banks, graphs and gameplay data. This repository -does not include an asset extraction pipeline. A source checkout alone cannot -run the game. +Development builds use a prepared asset set in `assets/private/` or the +first-run installation above. `Build-Release.ps1` builds the portable Windows +package with its setup helper; it also requires Python 3.13. Published GitHub +releases build and attach this package automatically. Custom animations and climbing support remain available, but no custom clips are shipped. The included format-demo map is original procedural content. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..8f0c0e2b --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +Bevy sources retain their MIT and Apache licenses under `vendor/`. + +`tools/vendor/utt` contains the MIT-licensed UTT RX2/model parsers by duckyinnit. +The full license is alongside the source. No UTT game payloads or executables +are bundled. + +The map exporters and archive readers come from the MIT-licensed Skate 3 +Custom Engine Layer. See `tools/vendor/university/LICENSE-PROJECT.md`. +The supplied preview.18 exporter has an explicit lossless v14 storage mode for +this runtime. The character scripts are the project's existing clone converters; +the ABIN/RX2 toolkit provenance is in `tools/vendor/skate3_anim/PROVENANCE.md`. + +The AttribSys converter uses the publicly documented VaultLib layout as a +reference: https://github.com/NFSTools/VaultLib. Its lookup8 hash and record +reader preserve data from the user's BIN/VLT files; no settings payload is +bundled. + +First-run setup downloads Blender 5.0.1 and XboxDev extract-xiso from their +publishers and verifies pinned SHA-256 hashes. Their distributions retain their +own license files. Game assets are never downloaded from this project. + +The setup executable bundles Python, NumPy, Pillow, Tcl/Tk and PyInstaller's +bootloader. Their license information is retained by the packager alongside +its bundled libraries. No game content is included in the release archive. diff --git a/crates/skate-data/src/attrib_hash.rs b/crates/skate-data/src/attrib_hash.rs new file mode 100644 index 00000000..44425685 --- /dev/null +++ b/crates/skate-data/src/attrib_hash.rs @@ -0,0 +1,36 @@ +//! AttribSys lookup8 identity. Numeric names preserve fields without debug strings. +fn mix(mut a: u64, mut b: u64, mut c: u64) -> (u64, u64, u64) { + for (x, y, z) in [(43, 9, 8), (38, 23, 5), (35, 49, 11), (12, 18, 22)] { + a = a.wrapping_sub(b).wrapping_sub(c) ^ (c >> x); + b = b.wrapping_sub(c).wrapping_sub(a) ^ (a << y); + c = c.wrapping_sub(a).wrapping_sub(b) ^ (b >> z); + } + (a, b, c) +} +pub fn hash(text: &str) -> u64 { + if text.is_empty() { return 0; } + let mut a = 0xabcdef0011223344u64; + let mut b = a; + let mut c = 0x9e3779b97f4a7c13u64; + let mut chunks = text.as_bytes().chunks_exact(24); + for chunk in &mut chunks { + let x = u64::from_le_bytes(chunk[..8].try_into().unwrap()); + let y = u64::from_le_bytes(chunk[8..16].try_into().unwrap()); + let z = u64::from_le_bytes(chunk[16..].try_into().unwrap()); + (a,b,c) = mix(a.wrapping_add(x),b.wrapping_add(y),c.wrapping_add(z)); + } + c = c.wrapping_add(text.len() as u64); + for (i, byte) in chunks.remainder().iter().enumerate() { + match i { + 0..=7 => a = a.wrapping_add(u64::from(*byte) << (i*8)), + 8..=15 => b = b.wrapping_add(u64::from(*byte) << ((i-8)*8)), + _ => c = c.wrapping_add(u64::from(*byte) << ((i-15)*8)), + } + } + mix(a,b,c).2 +} +pub fn numeric_name(text: &str) -> String { + let key = text.strip_prefix("Hash_").or_else(|| text.strip_prefix("0x")) + .and_then(|hex| u64::from_str_radix(hex,16).ok()).unwrap_or_else(|| hash(text)); + format!("Hash_{key:016X}") +} diff --git a/crates/skate-data/src/collections.rs b/crates/skate-data/src/collections.rs index 9d45c95c..2c3708ee 100644 --- a/crates/skate-data/src/collections.rs +++ b/crates/skate-data/src/collections.rs @@ -55,13 +55,17 @@ impl Collections { pub fn field(&self, class: &str, key: &str, name: &str) -> Result<&Field, String> { let mut current = key; + let class_hash = crate::attrib_hash::numeric_name(class); + let field_hash = crate::attrib_hash::numeric_name(name); for _ in 0..=self.collections.len() { + let key_hash = crate::attrib_hash::numeric_name(current); let item = self .collections .iter() - .find(|c| c.class_name == class && c.key == current) + .find(|c| (c.class_name == class || c.class_name == class_hash) + && (c.key == current || c.key == key_hash)) .ok_or_else(|| format!("Missing stock collection {class}/{current}"))?; - if let Some(field) = item.fields.get(name) { + if let Some(field) = item.fields.get(name).or_else(|| item.fields.get(&field_hash)) { return Ok(field); } if item.parent.is_empty() { diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index dfa9de94..9d1e8d04 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -7,6 +7,7 @@ pub mod animation_banks; pub mod animation_frames; pub mod animation_metadata; pub mod collections; +pub mod attrib_hash; pub mod input_config; pub mod gesture_patterns; pub mod physics_skeleton; diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs index 24facbd6..de4f1fdd 100644 --- a/crates/skate-data/src/skate_map.rs +++ b/crates/skate-data/src/skate_map.rs @@ -124,6 +124,44 @@ struct Reader<'a> { bytes: &'a [u8], at: usize, } + +/// MOBJ schema 3 stores editor ownership of ranges in the base geometry. +/// Physics-disabled objects can use those unchanged static arrays. Explicit +/// body types still require a runtime adapter and must not be flattened. +pub fn validate_static_objects(map: &SkateMap, extension: &Extension) -> Result<(), String> { + if extension.schema != 3 { return Err("Unsupported MOBJ schema".into()); } + let mut r = Reader { bytes: &extension.payload, at: 0 }; + let count = r.u()?; + r.check_count(count, 80)?; + let mut ids = std::collections::HashSet::new(); + for _ in 0..count { + if !ids.insert(r.u()?) { return Err("Duplicate MOBJ identity".into()); } + let name = r.string()?; + r.floats::<3>()?; + for limit in [map.geometry.indices.len(), map.geometry.collision.len()] { + let first = r.u()? as usize; + let length = r.u()? as usize; + if first.checked_add(length).is_none_or(|end| end > limit) { + return Err(format!("MOBJ {name} geometry range is invalid")); + } + } + let rails = r.u()?; + r.check_count(rails, 4)?; + for _ in 0..rails { + if r.u()? as usize >= map.rails.len() { return Err(format!("MOBJ {name} rail index is invalid")); } + } + if r.u()? != 0 { + return Err(format!("MOBJ {name} requests object physics, which requires a body adapter")); + } + if r.u()? > 2 { return Err(format!("MOBJ {name} collision shape is invalid")); } + r.floats::<6>()?; + for _ in 0..2 { + if r.u()? > 1 { return Err(format!("MOBJ {name} has an invalid boolean")); } + } + } + if r.at != r.bytes.len() { return Err("MOBJ has trailing data".into()); } + Ok(()) +} impl<'a> Reader<'a> { fn take(&mut self, n: usize) -> Result<&'a [u8], String> { let end = self.at.checked_add(n).ok_or("SKATE offset overflow")?; diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index f87cc2bd..60e4b20e 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -4,6 +4,14 @@ version.workspace = true edition.workspace = true publish.workspace = true +[[bin]] +name = "skate3rust" +path = "src/main.rs" + +[features] +default = ["dev-dynamic"] +dev-dynamic = ["bevy/dynamic_linking"] + [dependencies] bevy.workspace = true skate-core.workspace = true diff --git a/crates/skate-game/build.rs b/crates/skate-game/build.rs new file mode 100644 index 00000000..f389f28b --- /dev/null +++ b/crates/skate-game/build.rs @@ -0,0 +1,27 @@ +use std::{env, fs, path::PathBuf, process::Command}; + +fn main() { + println!("cargo:rerun-if-changed=../../docs/images/skating-crab.ico"); + println!("cargo:rerun-if-env-changed=RC"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let sdk = PathBuf::from(env::var_os("ProgramFiles(x86)").expect("Windows SDK location")) + .join("Windows Kits/10/bin"); + let rc = env::var_os("RC").map(PathBuf::from).or_else(|| { + let mut paths: Vec<_> = fs::read_dir(sdk).ok()?.filter_map(Result::ok) + .map(|entry| entry.path().join("x64/rc.exe")) + .filter(|path| path.is_file()).collect(); + paths.sort(); + paths.pop() + }).expect("Install the Windows SDK resource compiler or set RC to rc.exe"); + let icon = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()) + .join("../../docs/images/skating-crab.ico").canonicalize().unwrap(); + let output = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let script = output.join("skate3rust.rc"); + let resource = output.join("skate3rust.res"); + fs::write(&script, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/"))).unwrap(); + assert!(Command::new(rc).arg("/nologo").arg("/fo").arg(&resource).arg(script) + .status().expect("Run Windows resource compiler").success(), "Icon compilation failed"); + println!("cargo:rustc-link-arg-bin=skate3rust={}", resource.display()); +} diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 1d770685..7676e14c 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -46,7 +46,7 @@ pub(crate) fn build( }) .set(WindowPlugin { primary_window: Some(Window { - title: "Skate 3".into(), + title: "Skate 3 Rust Engine".into(), resolution: (1280, 800).into(), ..default() }), diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 280c31c1..3c56c2a6 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -6,18 +6,23 @@ pub(crate) struct Config { pub asset_root: PathBuf, pub verification_capture: Option, pub map: Option, + pub map_path: Option, pub difficulty: crate::difficulty::Difficulty, + pub check_assets: bool, } impl Config { pub fn from_env() -> Result { let mut config = Self { - asset_root: PathBuf::from("assets"), + asset_root: crate::setup::asset_root()?, verification_capture: None, map: None, + map_path: None, difficulty: crate::difficulty::Difficulty::default(), + check_assets: false, }; let mut difficulty_override = None; + let mut explicit_map = false; let mut args = std::env::args_os().skip(1); while let Some(arg) = args.next() { match arg.to_str() { @@ -27,7 +32,11 @@ impl Config { Some("--map") => { let path = PathBuf::from(args.next().ok_or("--map requires a .skate file")?); config.map = Some(skate_data::skate_map::SkateMap::load(&path)?); + config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?); + explicit_map = true; } + Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; } + Some("--check-assets") => config.check_assets = true, Some("--difficulty") => { let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?; difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?); @@ -41,7 +50,7 @@ impl Config { } _ => { return Err(format!( - "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--map MAP.skate] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png]" + "Unknown argument {arg:?}. Usage: skate3rust [--assets DIRECTORY] [--map MAP.skate | --test-world] [--difficulty easy|normal|hardcore] [--verify CAPTURE.png] [--check-assets]" )); } } @@ -54,6 +63,12 @@ impl Config { Some(mode) => mode, None => crate::difficulty::Difficulty::load(&config.asset_root)?, }; + if !explicit_map { + if let Some(path) = crate::map_library::default_map(&config.asset_root)? { + config.map = Some(skate_data::skate_map::SkateMap::load(&path)?); + config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?); + } + } if let Some(path) = &mut config.verification_capture { if path.extension().and_then(|x| x.to_str()) != Some("png") { return Err("--verify output must be a PNG file".into()); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index d9ad400d..a3d77f99 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -82,6 +82,8 @@ pub(crate) struct Menu { supported_msaa: Vec, difficulty: Difficulty, status: String, + maps: Vec, + selected_map: usize, } pub(crate) fn gameplay_active(menu: Option>) -> bool { menu.is_none_or(|m| !m.open) @@ -196,7 +198,7 @@ fn setup( BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..8 { + for i in 0..10 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); @@ -207,6 +209,8 @@ fn setup( }); }); commands.insert_resource(SceneTarget(target)); + let maps = crate::map_library::discover(&config.asset_root); + let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0); commands.insert_resource(Menu { open: false, selected: 0, @@ -215,6 +219,8 @@ fn setup( supported_msaa, difficulty: config.difficulty, status: String::new(), + maps, + selected_map, }); } fn msaa(samples: u32) -> Msaa { @@ -244,10 +250,10 @@ fn interact( let mut action = None; if menu.open { if keys.just_pressed(KeyCode::ArrowUp) { - menu.selected = (menu.selected + 7) % 8; + menu.selected = (menu.selected + 9) % 10; } if keys.just_pressed(KeyCode::ArrowDown) { - menu.selected = (menu.selected + 1) % 8; + menu.selected = (menu.selected + 1) % 10; } if keys.just_pressed(KeyCode::ArrowLeft) { action = Some((menu.selected, -1)); @@ -287,8 +293,19 @@ fn interact( Err(e) => format!("Applied, but could not save: {e}"), }; } - 6 => menu.open = false, + 6 => { + menu.selected_map = (menu.selected_map as i32 + direction) + .rem_euclid(menu.maps.len() as i32) as usize; + menu.status = "Choose Load map to switch".into(); + } 7 => { + match crate::map_library::switch(&config.asset_root, &menu.maps[menu.selected_map]) { + Ok(()) => { exit.write(AppExit::Success); } + Err(e) => menu.status = e, + } + } + 8 => menu.open = false, + 9 => { exit.write(AppExit::Success); } _ => {} @@ -404,7 +421,9 @@ fn labels( ), 4 => format!("Occlusion culling {}", if s.occlusion { "On" } else { "Off" }), 5 => format!("Difficulty {}", menu.difficulty.label()), - 6 => "Resume".into(), + 6 => format!("Map {}", menu.maps[menu.selected_map].label), + 7 => "Load map (restarts session)".into(), + 8 => "Resume".into(), _ => "Quit game".into(), }; } diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 4b1f4e82..4062ec95 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -4,6 +4,8 @@ mod app; mod assets; mod camera; mod config; +mod setup; +mod map_library; mod difficulty; mod graph_host; mod graph_runtime; @@ -71,6 +73,14 @@ fn main() -> bevy::app::AppExit { Ok(controls) => controls, Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); } }; + if config.check_assets { + if let Err(error) = camera::CameraRuntime::load(&config.asset_root) { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + eprintln!("SKATE_ASSETS_READY"); + return bevy::app::AppExit::Success; + } let mut app = app::build(config, manifest, graphs, physics, skater); app.insert_resource(controls); app.run() diff --git a/crates/skate-game/src/map_library.rs b/crates/skate-game/src/map_library.rs new file mode 100644 index 00000000..fcfc03c4 --- /dev/null +++ b/crates/skate-game/src/map_library.rs @@ -0,0 +1,62 @@ +//! Installed maps are selected by path; switching restarts the whole simulation. +//! This prevents old collision bodies, graph state and GPU assets surviving a change. +use std::{path::{Path, PathBuf}, process::Command}; + +pub(crate) struct Entry { + pub label: String, + pub path: Option, +} + +pub(crate) fn discover(assets: &Path) -> Vec { + let mut maps = Vec::new(); + let root = assets.parent().unwrap_or(assets).join("maps"); + for directory in [&root, &root.join("private")] { + if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|e| e.eq_ignore_ascii_case("skate")) { + maps.push(Entry { + label: path.file_stem().unwrap().to_string_lossy().replace('_', " "), + path: Some(path), + }); + } + } + } + } + maps.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase())); + maps.insert(0, Entry { label: "Test world".into(), path: None }); + maps +} + +pub(crate) fn default_map(assets: &Path) -> Result, String> { + // Only a completed release installation creates this pointer. Existing + // development checkouts continue to boot the test world. + let pointer = assets.parent().unwrap_or(assets).join("settings/default-map.json"); + let bytes = match std::fs::read(&pointer) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{}: {e}", pointer.display())), + }; + let relative: String = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let relative = Path::new(&relative); + if relative.is_absolute() || relative.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { + return Err("Invalid installed default map path".into()); + } + Ok(Some(assets.parent().unwrap_or(assets).join(relative))) +} + +pub(crate) fn switch(assets: &Path, entry: &Entry) -> Result<(), String> { + if let Some(path) = &entry.path { + let map = skate_data::skate_map::SkateMap::load(path)?; + crate::skate_world::validate_runtime(&map)?; + } + let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + command.arg("--assets").arg(assets); + if let Some(path) = &entry.path { + command.arg("--map").arg(path); + } else { + command.arg("--test-world"); + } + command.spawn().map_err(|e| format!("Could not open map: {e}"))?; + Ok(()) +} diff --git a/crates/skate-game/src/setup.rs b/crates/skate-game/src/setup.rs new file mode 100644 index 00000000..88dbebd8 --- /dev/null +++ b/crates/skate-game/src/setup.rs @@ -0,0 +1,46 @@ +//! Select local installed data before any stock-data or Bevy initialization. +use std::{path::{Path, PathBuf}, process::Command}; + +fn base() -> Result { + Ok(PathBuf::from(std::env::var_os("LOCALAPPDATA") + .ok_or("LOCALAPPDATA is unavailable; use --assets DIRECTORY")?).join("Skate3RustEngine")) +} +fn installed(base: &Path) -> Result, String> { + let bytes = match std::fs::read(base.join("installation.json")) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_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(_))) { + return Err("Invalid installation path".into()); + } + let assets = base.join(relative).join("assets"); + if !assets.join("private/game.json").is_file() { return Ok(None); } + Ok(Some(assets)) +} + +pub(crate) fn asset_root() -> Result { + let explicit = std::env::args_os().any(|arg| arg == "--assets"); + if explicit { return Ok(PathBuf::from("assets")); } + // Existing developer launches retain their explicit local assets. + if Path::new("assets/private/game.json").is_file() { return Ok(PathBuf::from("assets")); } + let base = base()?; + if let Some(assets) = installed(&base)? { return Ok(assets); } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let setup = exe.parent().ok_or("No executable directory")?.join("support/skate3setup.exe"); + if !setup.is_file() { + return Err("No installed assets. Use the complete Windows release package, or --assets DIRECTORY for a prepared development asset set.".into()); + } + let mut command = Command::new(setup); + command.arg("--base").arg(&base).arg("--game-exe").arg(&exe); + #[cfg(windows)] { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let status = command.status().map_err(|e| format!("Could not start setup: {e}"))?; + if !status.success() { return Err("Setup was cancelled or did not complete".into()); } + installed(&base)?.ok_or_else(|| "Setup did not publish a complete installation".into()) +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index 6563bb41..96a32a9e 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -42,6 +42,10 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { if extension.tag == *b"RWCM" { continue; } + if extension.tag == *b"MOBJ" { + skate_data::skate_map::validate_static_objects(map, extension)?; + continue; + } if extension.tag == *b"SKYB" && extension.schema == 1 { eprintln!( "SKATE LIMITATION: SKYB retail sky retained; using the map horizon until its shader adapter is available." diff --git a/docs/images/skating-crab.ico b/docs/images/skating-crab.ico new file mode 100644 index 00000000..6f9e2b70 Binary files /dev/null and b/docs/images/skating-crab.ico differ diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..fc05f406 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,39 @@ +# Windows installation + +`Build-Release.ps1` produces `target/skate3rust-windows-x64.zip`. Keep its +`support` directory beside `skate3rust.exe`. The game links Bevy and the MSVC +runtime statically; the setup helper bundles Python, NumPy, Pillow and Tcl/Tk. + +On first launch, setup asks for a local Xbox 360 Skate 3 ISO. It downloads +hash-pinned portable Blender and extract-xiso distributions from their +publishers. No game files are downloaded or included in the package. + +The conversion pipeline: + +1. Extracts the disc into a new installation workspace. +2. Preserves native animation banks and state graphs from `miscload.big`. +3. Reads the big-endian 64-bit AttribSys schema and collections from `db.big`. + Settings are decoded from the disc, with numeric hashes retained for names + absent from debug strings. The JSON export serves the fields consumed by + this engine; it is not a general VLT editor or portable array serialization. +4. Builds the default modular skater from the CAC archive, including textures, + morph settings, the native rig and its four board-relative IK targets. + Gameplay animations are evaluated from ABIN by the Rust runtime. +5. Converts each `worldDIST_*.big` into a `.skate` map. The supplied map exporter + runs in v14 storage mode, with native collision archives and grind splines + embedded. University uses the supplied exporter's starting location. Other + districts select a broad upward collision face near the district origin. +6. Runs the game's asset and map loaders before publishing `installation.json`. + 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 +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. + +The Escape menu discovers installed `.skate` files. Loading a map starts a new +game process and closes the current session so physics and render resources +are recreated. A fresh launch defaults to University. Development checkouts +with `assets/private/game.json` retain their existing local assets and default +test world; `--assets DIRECTORY` also selects a prepared asset set explicitly. diff --git a/tools/add_onboard_ik_targets.py b/tools/add_onboard_ik_targets.py new file mode 100644 index 00000000..0e498920 --- /dev/null +++ b/tools/add_onboard_ik_targets.py @@ -0,0 +1,240 @@ +"""Add Skate 3's four compact OnBoard SkeletonIK targets to the Bevy rig. + +The RX2 mesh exporter intentionally creates only deform and board bones. The +retail compact OnBoard hierarchy continues with toe channels 32/33 and hand +channels 34/35, which are board-parented SkeletonIK targets. This post-process +decodes those channels from every selected retail action and bakes them as +non-deforming glTF bones. +""" + +from __future__ import annotations + +import math +from pathlib import Path +import sys + +import bpy +from mathutils import Matrix, Vector +import numpy as np + + +TARGETS = { + 32: "RIGHTTOEBASE_REPARENTED", + 33: "LEFTTOEBASE_REPARENTED", + 34: "LEFTHAND_REPARENTED", + 35: "RIGHTHAND_REPARENTED", +} +BOARD = "SKATEBOARD_ROOT" +SOURCE_TO_BLENDER = Matrix( + ( + (1.0, 0.0, 0.0, 0.0), + (0.0, 0.0, -1.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + ) +) +BLENDER_TO_SOURCE = SOURCE_TO_BLENDER.inverted() + + +def parse_args() -> tuple[Path, Path]: + if "--" not in sys.argv: + raise RuntimeError("Expected ABIN and importer-directory paths after --") + tail = sys.argv[sys.argv.index("--") + 1 :] + if len(tail) != 2: + raise RuntimeError( + "Usage: blender source.blend --python add_onboard_ik_targets.py " + "-- OnBoard.abin sk8anims" + ) + return Path(tail[0]).resolve(), Path(tail[1]).resolve() + + +def sqt_matrix(sqt) -> np.ndarray: + x, y, z, w = (float(value) for value in sqt.quat) + sx, sy, sz = (float(value) for value in sqt.scale) + norm = math.sqrt(x * x + y * y + z * z + w * w) + if norm < 1.0e-12: + x = y = z = 0.0 + w = 1.0 + else: + x, y, z, w = (x / norm, y / norm, z / norm, w / norm) + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + result = np.identity(4, dtype=np.float64) + result[0, :3] = ( + (1.0 - 2.0 * (yy + zz)) * sx, + (2.0 * (xy + wz)) * sx, + (2.0 * (xz - wy)) * sx, + ) + result[1, :3] = ( + (2.0 * (xy - wz)) * sy, + (1.0 - 2.0 * (xx + zz)) * sy, + (2.0 * (yz + wx)) * sy, + ) + result[2, :3] = ( + (2.0 * (xz + wy)) * sz, + (2.0 * (yz - wx)) * sz, + (1.0 - 2.0 * (xx + yy)) * sz, + ) + result[3, :3] = tuple(float(value) for value in sqt.trans) + return result + + +def source_row_to_blender(matrix: np.ndarray) -> Matrix: + source_column = Matrix(np.asarray(matrix, dtype=np.float64).T.tolist()) + return SOURCE_TO_BLENDER @ source_column @ BLENDER_TO_SOURCE + + +def compose_world(frame, reference, parents) -> dict[int, np.ndarray]: + world = {} + for index, parent in enumerate(parents): + delta = sqt_matrix(frame[index]) if index in frame else np.identity(4) + rest = ( + sqt_matrix(reference[index]) + if index in reference + else np.identity(4) + ) + local = delta @ rest + world[index] = local @ world[parent] if parent >= 0 else local + return world + + +def add_target_bones(armature, reference, parents) -> None: + rest_world = compose_world({}, reference, parents) + bpy.ops.object.select_all(action="DESELECT") + armature.select_set(True) + bpy.context.view_layer.objects.active = armature + bpy.ops.object.mode_set(mode="EDIT") + board = armature.data.edit_bones[BOARD] + for index, name in TARGETS.items(): + if name in armature.data.edit_bones: + continue + matrix = source_row_to_blender(rest_world[index]) + rotation = matrix.to_3x3().normalized() + y_axis = (rotation @ Vector((0.0, 1.0, 0.0))).normalized() + z_axis = (rotation @ Vector((0.0, 0.0, 1.0))).normalized() + bone = armature.data.edit_bones.new(name) + bone.head = matrix.translation + bone.tail = bone.head + y_axis * 0.08 + bone.align_roll(z_axis) + bone.parent = board + bone.use_connect = False + bone.use_deform = False + bpy.ops.object.mode_set(mode="OBJECT") + + +def bake_targets(armature, data, abin, reference, parents) -> tuple[int, float]: + clips = {clip.header.name: (index, clip) for index, clip in enumerate(abin.clips)} + tracks = list(armature.animation_data.nla_tracks) + track_mutes = [track.mute for track in tracks] + for track in tracks: + track.mute = True + + baked_keys = 0 + maximum_error = 0.0 + try: + for action in bpy.data.actions: + match = clips.get(action.name) + if match is None: + continue + clip_index, clip = match + armature.animation_data.action = action + decoders = {} + for frame_index in range(clip.num_frames): + scene_frame = frame_index + 1 + bpy.context.scene.frame_set(scene_frame) + decoded = __import__("abin_importer").decode_clip_frame( + data, + clip, + decoders, + frame_index, + abin.hierarchy, + ) + source_world = compose_world(decoded, reference, parents) + board_world = armature.pose.bones[BOARD].matrix.copy() + board_rest = armature.data.bones[BOARD].matrix_local + + expected = {} + for index, name in TARGETS.items(): + target_world = source_row_to_blender(source_world[index]) + target_rest = armature.data.bones[name].matrix_local + local_rest = board_rest.inverted() @ target_rest + pose_bone = armature.pose.bones[name] + pose_bone.rotation_mode = "QUATERNION" + pose_bone.matrix_basis = ( + local_rest.inverted() + @ board_world.inverted() + @ target_world + ) + expected[name] = target_world + + for name in TARGETS.values(): + pose_bone = armature.pose.bones[name] + pose_bone.keyframe_insert( + data_path="location", frame=scene_frame, group=name + ) + pose_bone.keyframe_insert( + data_path="rotation_quaternion", + frame=scene_frame, + group=name, + ) + pose_bone.keyframe_insert( + data_path="scale", frame=scene_frame, group=name + ) + baked_keys += 10 + + bpy.context.view_layer.update() + for name, target_world in expected.items(): + actual = armature.pose.bones[name].matrix + maximum_error = max( + maximum_error, + max( + abs(actual[row][column] - target_world[row][column]) + for row in range(4) + for column in range(4) + ), + ) + finally: + armature.animation_data.action = None + for track, mute in zip(tracks, track_mutes): + track.mute = mute + return baked_keys, maximum_error + + +def main() -> None: + abin_path, importer_dir = parse_args() + sys.path.insert(0, str(importer_dir)) + import abin_importer as abin_importer + + data = abin_path.read_bytes() + abin = abin_importer.AbinFile(data) + if abin.hierarchy is None: + raise RuntimeError("OnBoard ABIN does not contain a hierarchy") + poses = [pose for pose in abin.poses if pose.header.name == "RIG_TPOSE"] + if not poses: + raise RuntimeError("OnBoard ABIN does not contain RIG_TPOSE") + # Match AnimSource._load_ref_pose, which supplies the reference used by + # the already-baked deform/board actions in this same Blender file. + reference = abin_importer.decode_pose_frame(data, poses[0], abin.hierarchy) + parents = list(abin.hierarchy.parents) + if len(parents) <= max(TARGETS): + raise RuntimeError("OnBoard hierarchy does not contain target channels 32-35") + + armature = next(obj for obj in bpy.data.objects if obj.type == "ARMATURE") + armature.animation_data_create() + add_target_bones(armature, reference, parents) + key_count, maximum_error = bake_targets( + armature, data, abin, reference, parents + ) + if maximum_error > 5.0e-5: + raise RuntimeError( + f"SkeletonIK target bake exceeded tolerance: {maximum_error:.9g}" + ) + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + print( + "SKATE3_ONBOARD_IK_TARGETS " + f"targets=4 keys={key_count} max_error={maximum_error:.9g}" + ) + + +main() diff --git a/tools/apply_default_skater_materials.py b/tools/apply_default_skater_materials.py new file mode 100644 index 00000000..e5fc28ab --- /dev/null +++ b/tools/apply_default_skater_materials.py @@ -0,0 +1,575 @@ +"""Restore retail CAC UVs, normals, texture maps, and material policy in Blender.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from pathlib import Path +import struct +import sys + +import bpy +from mathutils import Vector + + +TYPE_RAW_BUFFER = 0x00010031 +TYPE_VTX_DESC = 0x000200E9 +TYPE_VB_DESC = 0x000200EA +TYPE_VTX_DECL = 0x00020081 +TYPE_MESH_DESC = 0x00EB0023 +TYPE_MORPH_DESC = 0x00EB000E +MORPH_VERTEX_STRIDE = 16 +MORPH_POSITION_SCALE = 1.0 / 16384.0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--rx2-dir", required=True) + parser.add_argument("--manifest", required=True) + parser.add_argument("--material-dir", required=True) + parser.add_argument("--parser-dir", required=True) + parser.add_argument("--report", required=True) + return parser.parse_args(sys.argv[sys.argv.index("--") + 1 :]) + + +def load_image(path: Path, *, color: bool) -> bpy.types.Image: + image = bpy.data.images.load(str(path), check_existing=True) + image.colorspace_settings.name = "sRGB" if color else "Non-Color" + image.pack() + return image + + +def principled_input(node: bpy.types.Node, *names: str): + for name in names: + socket = node.inputs.get(name) + if socket is not None: + return socket + raise RuntimeError( + f"Principled BSDF {node.name} is missing inputs {', '.join(names)}" + ) + + +def create_material( + component: dict, material_dir: Path +) -> bpy.types.Material: + slot = component["slot"] + material = bpy.data.materials.new( + f"Retail_{slot}_{component['material_id']}" + ) + material.use_nodes = True + material.diffuse_color = (*component["tint"], 1.0) + material.use_backface_culling = True + nodes = material.node_tree.nodes + links = material.node_tree.links + nodes.clear() + + output = nodes.new("ShaderNodeOutputMaterial") + shader = nodes.new("ShaderNodeBsdfPrincipled") + links.new(shader.outputs["BSDF"], output.inputs["Surface"]) + + base_path = material_dir / f"{slot}_base_color.png" + base_texture = nodes.new("ShaderNodeTexImage") + base_texture.name = f"{slot}_BaseColor" + base_texture.image = load_image(base_path, color=True) + # Use Blender's glTF-recognized RGBA Mix node so the retail linear tint + # is exported as pbrMetallicRoughness.baseColorFactor. The legacy + # ShaderNodeMixRGB looks correct in Blender but is ignored by glTF. + tint = nodes.new("ShaderNodeMix") + tint.data_type = "RGBA" + tint.blend_type = "MULTIPLY" + tint.inputs[0].default_value = 1.0 + tint.inputs[7].default_value = (*component["tint"], 1.0) + links.new(base_texture.outputs["Color"], tint.inputs[6]) + links.new(tint.outputs[2], principled_input(shader, "Base Color")) + + if component.get("alpha_mode") == "MASK": + cutoff = float(component.get("alpha_cutoff", 0.5)) + # Blender 5's glTF exporter derives alphaMode from the shader graph. + # A direct texture-alpha link exports BLEND even when Eevee previews a + # dithered surface. The explicit comparison is recognized as MASK and + # preserves the retail hard-cutout policy in Bevy. + alpha_clip = nodes.new("ShaderNodeMath") + alpha_clip.name = f"{slot}_AlphaCutoff" + alpha_clip.operation = "GREATER_THAN" + alpha_clip.inputs[1].default_value = cutoff + links.new(base_texture.outputs["Alpha"], alpha_clip.inputs[0]) + links.new(alpha_clip.outputs[0], principled_input(shader, "Alpha")) + material.surface_render_method = "DITHERED" + material["skate3_alpha_mode"] = "MASK" + material["skate3_alpha_cutoff"] = cutoff + else: + material["skate3_alpha_mode"] = "OPAQUE" + + normal_id = component["textures"].get("normal") + if normal_id: + normal_path = material_dir / f"{slot}_normal.png" + if not normal_path.is_file(): + raise RuntimeError( + f"{slot} reconstructed DXT5nm texture is missing: {normal_path}" + ) + normal_texture = nodes.new("ShaderNodeTexImage") + normal_texture.name = f"{slot}_Normal" + normal_texture.image = load_image(normal_path, color=False) + normal = nodes.new("ShaderNodeNormalMap") + normal.space = "TANGENT" + normal.inputs["Strength"].default_value = 1.0 + links.new(normal_texture.outputs["Color"], normal.inputs["Color"]) + links.new(normal.outputs["Normal"], principled_input(shader, "Normal")) + + roughness_path = material_dir / f"{slot}_roughness.png" + if roughness_path.is_file(): + roughness_texture = nodes.new("ShaderNodeTexImage") + roughness_texture.name = f"{slot}_Roughness" + roughness_texture.image = load_image(roughness_path, color=False) + links.new( + roughness_texture.outputs["Color"], + principled_input(shader, "Roughness"), + ) + else: + principled_input(shader, "Roughness").default_value = ( + 0.48 if slot in {"SkateTruck", "SkateWheel"} else 0.72 + ) + principled_input(shader, "Metallic").default_value = ( + 0.65 if slot == "SkateTruck" else 0.0 + ) + + material["skate3_slot"] = slot + material["skate3_asset_id"] = component["asset_id"] + material["skate3_model_id"] = component["model_id"] + material["skate3_material_id"] = component["material_id"] + material["skate3_texture_ids"] = json.dumps( + component["textures"], sort_keys=True + ) + return material + + +def max_position_error( + obj: bpy.types.Object, positions: list[tuple[float, float, float]], convert +) -> float: + return max( + ( + Vector(obj.data.vertices[index].co) - Vector(convert(position)) + ).length + for index, position in enumerate(positions) + ) + + +def be_u32(data: bytes, offset: int) -> int: + return struct.unpack_from(">I", data, offset)[0] + + +def morph_weight(name: str, manifest: dict) -> float: + body = manifest["preset"]["body_mods"] + if name == "fat" or name.startswith("fat_"): + return float(body["fatness"]) + if name == "thin" or name.startswith("thin_"): + return float(body["skinniness"]) + if name in manifest["morph_assembly"]["face_targets"]: + return float(body["face_fields"]) + raise RuntimeError(f"Retail morph target has no recipe mapping: {name}") + + +def decode_dense_morphs( + path: Path, parsed: dict, vertex_count: int, rx2_skeleton +) -> list[dict]: + data = path.read_bytes() + sections = parsed["sections"] + mesh_descs = [ + section + for section in sections + if section["type_code"] == TYPE_MESH_DESC + ] + if len(mesh_descs) != 1: + raise RuntimeError( + f"{path.name} expected one mesh descriptor, found {len(mesh_descs)}" + ) + base = mesh_descs[0]["file_offset"] + morph_count = be_u32(data, base + 0x50) + morph_table = be_u32(data, base + 0x54) + morphs = [] + for morph_index in range(morph_count): + entry = base + morph_table + morph_index * 16 + target_hash, descriptor_index, name_offset = struct.unpack_from( + ">QII", data, entry + ) + name_start = base + name_offset + name_end = data.find(b"\0", name_start) + if name_end < 0: + raise RuntimeError( + f"{path.name} morph {morph_index} has no terminated name" + ) + name = data[name_start:name_end].decode("ascii") + if descriptor_index < 4 or descriptor_index >= len(sections): + raise RuntimeError( + f"{path.name} morph {name} has invalid descriptor index " + f"{descriptor_index}" + ) + expected_types = ( + TYPE_RAW_BUFFER, + TYPE_VB_DESC, + TYPE_VTX_DECL, + TYPE_VTX_DESC, + TYPE_MORPH_DESC, + ) + stream_sections = sections[descriptor_index - 4 : descriptor_index + 1] + actual_types = tuple( + section["type_code"] for section in stream_sections + ) + if actual_types != expected_types: + raise RuntimeError( + f"{path.name} morph {name} stream layout changed: " + f"{actual_types} != {expected_types}" + ) + raw, vb_desc, _vtx_decl, vtx_desc, _morph_desc = stream_sections + byte_size = be_u32(data, vb_desc["file_offset"] + 0x20) + if byte_size != vertex_count * MORPH_VERTEX_STRIDE: + raise RuntimeError( + f"{path.name} morph {name} byte size changed: " + f"{byte_size} != {vertex_count * MORPH_VERTEX_STRIDE}" + ) + if raw["size"] != byte_size: + raise RuntimeError( + f"{path.name} morph {name} raw-buffer size changed" + ) + descriptor = rx2_skeleton._parse_vertex_descriptor( + data, vtx_desc["file_offset"], vtx_desc["size"] + ) + expected_elements = [ + ("POSITION", 0, "SHORT4", 0), + ("NORMAL", 0, "PACKED11_11_10N", 8), + ("BLENDWEIGHT", 0, "UNKNOWN_0x002C83A4", 12), + ] + actual_elements = [ + ( + element["usage_name"], + element["usage_index"], + element["format_name"], + element["offset"], + ) + for element in descriptor["elements"] + ] + if ( + descriptor["stride"] != MORPH_VERTEX_STRIDE + or actual_elements != expected_elements + ): + raise RuntimeError( + f"{path.name} morph {name} vertex declaration changed" + ) + + deltas = [] + nonzero_vertices = 0 + nonzero_normal_deltas = 0 + max_delta = 0.0 + for vertex_index in range(vertex_count): + vertex_offset = ( + raw["file_offset"] + vertex_index * MORPH_VERTEX_STRIDE + ) + x, y, z, _remap = struct.unpack_from( + ">4h", data, vertex_offset + ) + if be_u32(data, vertex_offset + 8) != 0: + nonzero_normal_deltas += 1 + if be_u32(data, vertex_offset + 12) != 0x3F800000: + raise RuntimeError( + f"{path.name} morph {name} has an unhandled vertex factor" + ) + delta = ( + x * MORPH_POSITION_SCALE, + y * MORPH_POSITION_SCALE, + z * MORPH_POSITION_SCALE, + ) + length = math.sqrt(sum(value * value for value in delta)) + if length > 0.0: + nonzero_vertices += 1 + max_delta = max(max_delta, length) + deltas.append(delta) + morphs.append( + { + "name": name, + "hash": f"{target_hash:016x}", + "descriptor_index": descriptor_index, + "deltas": deltas, + "nonzero_vertices": nonzero_vertices, + "nonzero_normal_deltas": nonzero_normal_deltas, + "max_source_delta": max_delta, + } + ) + return morphs + + +def apply_retail_morphs( + obj: bpy.types.Object, + source_positions: list[tuple[float, float, float]], + morphs: list[dict], + manifest: dict, + convert, +) -> dict: + combined = [[0.0, 0.0, 0.0] for _ in source_positions] + weights = {} + for morph in morphs: + weight = morph_weight(morph["name"], manifest) + weights[morph["name"]] = weight + if weight == 0.0: + continue + if morph["nonzero_normal_deltas"] != 0: + raise RuntimeError( + f"Active retail morph {morph['name']} has " + f"{morph['nonzero_normal_deltas']} unhandled normal deltas" + ) + for vertex_index, delta in enumerate(morph["deltas"]): + for axis in range(3): + combined[vertex_index][axis] += weight * delta[axis] + + morphed_positions = [] + moved_vertices = 0 + max_delta = 0.0 + digest = hashlib.sha256() + for vertex_index, (position, delta) in enumerate( + zip(source_positions, combined) + ): + morphed = tuple( + float(position[axis] + delta[axis]) for axis in range(3) + ) + morphed_positions.append(morphed) + source_delta_length = math.sqrt(sum(value * value for value in delta)) + if source_delta_length > 0.0: + moved_vertices += 1 + max_delta = max(max_delta, source_delta_length) + obj.data.vertices[vertex_index].co += convert(delta) + digest.update(struct.pack(">3f", *morphed)) + + return { + "targets": [ + { + "name": morph["name"], + "hash": morph["hash"], + "descriptor_index": morph["descriptor_index"], + "weight": weights[morph["name"]], + "nonzero_vertices": morph["nonzero_vertices"], + "nonzero_normal_deltas": morph[ + "nonzero_normal_deltas" + ], + "max_source_delta": morph["max_source_delta"], + } + for morph in morphs + ], + "moved_vertices": moved_vertices, + "maximum_applied_source_delta": max_delta, + "first_morphed_source_position": list(morphed_positions[0]), + "morphed_source_positions_sha256": digest.hexdigest().upper(), + } + + +def main() -> None: + args = parse_args() + rx2_dir = Path(args.rx2_dir).resolve() + manifest_path = Path(args.manifest).resolve() + material_dir = Path(args.material_dir).resolve() + parser_dir = Path(args.parser_dir).resolve() + report_path = Path(args.report).resolve() + sys.path.insert(0, str(parser_dir)) + import blender_rx2_abin_export as exporter + import rx2_skeleton + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + components = {item["slot"]: item for item in manifest["components"]} + objects = { + obj.name.split("_", 1)[0]: obj + for obj in bpy.data.objects + if obj.type == "MESH" + } + expected_slots = set(components) + missing = sorted(expected_slots - set(objects)) + extra = sorted(set(objects) - expected_slots) + if missing or extra: + raise RuntimeError( + f"Default-skater mesh set mismatch: missing={missing} extra={extra}" + ) + + reports = [] + total_vertices = 0 + total_triangles = 0 + for slot, component in components.items(): + path = rx2_dir / slot / f"0x{component['model_id']}.rx2" + parsed = rx2_skeleton.parse_rx2(str(path)) + errors = rx2_skeleton.validate(parsed) + tolerated = [ + error + for error in errors + if "no canonical parent known" not in error + ] + if tolerated: + raise RuntimeError(f"{slot} RX2 validation failed: {tolerated}") + meshes = [ + mesh + for mesh in parsed["meshes"] + if mesh.get("positions") and mesh.get("indices") + ] + if len(meshes) != 1: + raise RuntimeError( + f"{slot} expected one renderable mesh, found {len(meshes)}" + ) + source = meshes[0] + obj = objects[slot] + positions = source["positions"] + if len(obj.data.vertices) != len(positions): + raise RuntimeError( + f"{slot} vertex count changed: " + f"{len(obj.data.vertices)} != {len(positions)}" + ) + position_error = max_position_error( + obj, positions, exporter.point_source_to_blender + ) + if position_error > 1.0e-6: + raise RuntimeError( + f"{slot} vertex-order mismatch: {position_error:.9g} m" + ) + morphs = decode_dense_morphs( + path, parsed, len(positions), rx2_skeleton + ) + expected_morphs = manifest["morph_assembly"]["expected_targets"][slot] + actual_morphs = [morph["name"] for morph in morphs] + if actual_morphs != expected_morphs: + raise RuntimeError( + f"{slot} morph target order changed: " + f"{actual_morphs} != {expected_morphs}" + ) + morph_report = apply_retail_morphs( + obj, + positions, + morphs, + manifest, + exporter.point_source_to_blender, + ) + + uvs = source.get("uvs") + if not uvs or len(uvs) != len(positions): + raise RuntimeError(f"{slot} has no complete retail UV set") + while obj.data.uv_layers: + obj.data.uv_layers.remove(obj.data.uv_layers[0]) + uv_layer = obj.data.uv_layers.new(name="TEXCOORD_0") + for loop in obj.data.loops: + uv = uvs[loop.vertex_index] + # RX2 uses the Direct3D/top-left texture convention. Blender UVs + # are bottom-left and its glTF exporter flips them back on export. + uv_layer.data[loop.index].uv = ( + float(uv[0]), + 1.0 - float(uv[1]), + ) + + normals = source.get("normals") + if not normals or len(normals) != len(positions): + raise RuntimeError(f"{slot} has no complete retail normal set") + converted_normals = [ + Vector((normal[0], -normal[2], normal[1])).normalized() + for normal in normals + ] + if hasattr(obj.data, "normals_split_custom_set_from_vertices"): + obj.data.normals_split_custom_set_from_vertices(converted_normals) + elif hasattr(obj.data, "normals_split_custom_set"): + obj.data.normals_split_custom_set( + [converted_normals[loop.vertex_index] for loop in obj.data.loops] + ) + else: + raise RuntimeError("This Blender build cannot store custom normals") + + obj.data.materials.clear() + obj.data.materials.append(create_material(component, material_dir)) + obj["skate3_retail_default_component"] = True + obj["skate3_lod"] = 0 + obj["skate3_source_model_id"] = component["model_id"] + obj["skate3_native_rx2_bones"] = len(parsed["bones"]) + + influence_groups = { + group.name + for group in obj.vertex_groups + if any( + group.index == assignment.group and assignment.weight > 1.0e-6 + for vertex in obj.data.vertices + for assignment in vertex.groups + ) + } + if not influence_groups: + raise RuntimeError(f"{slot} has no bound vertex groups") + if any( + not math.isfinite(value) + for vertex in obj.data.vertices + for value in vertex.co + ): + raise RuntimeError(f"{slot} contains a non-finite vertex") + + triangles = len(source["indices"]) // 3 + total_vertices += len(positions) + total_triangles += triangles + reports.append( + { + "slot": slot, + "vertices": len(positions), + "triangles": triangles, + "native_bones": len(parsed["bones"]), + "runtime_vertex_groups": len(influence_groups), + "position_error": position_error, + "morphs": morph_report, + "uvs": len(uvs), + "first_rx2_uv": [float(uvs[0][0]), float(uvs[0][1])], + "normals": len(normals), + "tangents": len(source.get("tangents") or []), + "degenerate_tangent_frames": source.get( + "degenerate_tangent_frames", 0 + ), + "material": obj.data.materials[0].name, + } + ) + + if total_vertices != manifest["rig_evidence"]["selected_vertices"]: + raise RuntimeError( + f"Vertex total changed: {total_vertices} != " + f"{manifest['rig_evidence']['selected_vertices']}" + ) + if total_triangles != manifest["rig_evidence"]["selected_triangles"]: + raise RuntimeError( + f"Triangle total changed: {total_triangles} != " + f"{manifest['rig_evidence']['selected_triangles']}" + ) + + bpy.context.scene["skate3_default_preset"] = manifest["preset"]["name"] + bpy.context.scene["skate3_default_recipe_sha256"] = manifest["preset"][ + "recipe_sha256" + ] + bpy.context.scene["skate3_retail_modular_parts"] = len(reports) + bpy.context.scene["skate3_retail_vertices"] = total_vertices + bpy.context.scene["skate3_retail_triangles"] = total_triangles + bpy.context.scene["skate3_retail_uvs_restored"] = True + bpy.context.scene["skate3_retail_normals_restored"] = True + bpy.context.scene["skate3_generated_weights"] = False + bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) + + report = { + "schema": 1, + "normal_encoding": "dxt5nm-ag-to-gltf-rgb-v1", + "uv_encoding": "rx2-top-left-to-blender-bottom-left-v1", + "tint_encoding": "gltf-linear-base-color-factor-v1", + "alpha_encoding": "gltf-mask-retail-alpha-v1", + "morph_encoding": manifest["morph_assembly"]["encoding"], + "preset": manifest["preset"]["name"], + "recipe_sha256": manifest["preset"]["recipe_sha256"], + "parts": reports, + "vertices": total_vertices, + "triangles": total_triangles, + "generated_weights": False, + "runtime_bind_policy": manifest["rig_evidence"]["runtime_policy"], + } + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print( + "DEFAULT_SKATER_MATERIALS_OK " + f"parts={len(reports)} vertices={total_vertices} " + f"triangles={total_triangles}" + ) + + +main() diff --git a/tools/asset_pipeline/build_map.py b/tools/asset_pipeline/build_map.py new file mode 100644 index 00000000..9032edc5 --- /dev/null +++ b/tools/asset_pipeline/build_map.py @@ -0,0 +1,36 @@ +"""Build any disc district using the supplied retail scene and map exporters.""" +from pathlib import Path +import os,sys +import bpy + +tools,manifest,blend,package=map(Path,sys.argv[sys.argv.index('--')+1:sys.argv.index('--')+5]) +district=sys.argv[-1] +base=tools/'vendor/university/tools' +sys.path.insert(0,str(base/'vanilla_map_extraction/blender')) +from import_hawaiian_dream import build_scene +import prepare_university_owned as owned +summary=build_scene(manifest) +if summary['objects']!=summary['expected_objects']: + raise RuntimeError('District import omitted objects') +if district!='DIST_University': + # Other districts have different world-space bounds. Choose an actual + # broad upward collision face, rather than spawning at University's XZ. + candidates=[] + for obj in bpy.data.objects: + if obj.type!='MESH' or not obj.get('skate3_retail_collision',False): continue + for face in obj.data.polygons: + if face.normal.z>0.9 and face.area>2: + p=obj.matrix_world@face.center + candidates.append((p.x*p.x+p.y*p.y,p)) + if not candidates: raise RuntimeError(f'No walkable spawn surface in {district}') + p=min(candidates,key=lambda item:item[0])[1] + owned.SPAWN_RUNTIME_XZ=(p.x,-p.y) + owned.SPAWN_TARGET_HEIGHT=p.z +sys.argv=['owned','--',str(blend)] +owned.main() +bpy.context.scene['ow_map_name']=district.removeprefix('DIST_') +os.environ['SKATE_EXPORT_COMPAT14']='1' +sys.path.insert(0,str(base/'blender_owned_map')) +from owned_world_material_addon.exporter import main +sys.argv=['export','--',str(package),'--force'] +main() diff --git a/tools/asset_pipeline/finish_character.py b/tools/asset_pipeline/finish_character.py new file mode 100644 index 00000000..90bcbd43 --- /dev/null +++ b/tools/asset_pipeline/finish_character.py @@ -0,0 +1,16 @@ +"""Apply the retail modular-skater materials and export the native rig to GLB.""" +from pathlib import Path +import runpy,sys + +tools,work,private=map(Path,sys.argv[sys.argv.index('--')+1:]) +sys.argv=['materials','--','--rx2-dir',str(work/'selected/models'), + '--manifest',str(tools/'default_skater_retail_manifest.json'), + '--material-dir',str(private/'default_skater/textures/materials'), + '--parser-dir',str(tools/'vendor/skate3_anim'), + '--report',str(work/'materials.json')] +runpy.run_path(str(tools/'apply_default_skater_materials.py'),run_name='__main__') +sys.argv=['targets','--',str(private/'stock/data/anim/OnBoard.abin'),str(tools/'vendor/skate3_anim')] +runpy.run_path(str(tools/'add_onboard_ik_targets.py'),run_name='__main__') +sys.argv=['export','--',str(private/'skater.glb'),str(private/'skater.manifest.txt'), + str(private/'skater.root_motion.json')] +runpy.run_path(str(tools/'export_bevy_glb.py'),run_name='__main__') diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py new file mode 100644 index 00000000..486dfd9f --- /dev/null +++ b/tools/asset_pipeline/install.py @@ -0,0 +1,204 @@ +"""Local owned-disc installation. No game content is downloaded or packaged.""" +from pathlib import Path +import hashlib,json,os,shutil,subprocess,sys,urllib.request,uuid,zipfile +from tools.owned_game.big import BigArchive +from .vlt import convert as convert_vlt +from .physics_skeleton import convert as convert_skeleton + +TOOLS=Path(__file__).resolve().parents[1] +BLENDER_URL='https://download.blender.org/release/Blender5.0/blender-5.0.1-windows-x64.zip' +BLENDER_SHA='921d77f6c505a35b2c2f6e67d4ad1c10b72418338ba0e0d3ea7f582a5e5fe46e' +XISO_URL='https://github.com/XboxDev/extract-xiso/releases/download/build-202505152050/extract-xiso-Win64_Release.zip' +XISO_SHA='fec88d03c7efd6205ab09be4abba70c0afd0eb27a5709f0a6235b828ba5ac11e' + +def digest(path): + with path.open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest() + +def remove_intermediate(path,root): + target=path.resolve();root=root.resolve() + if target==root or not target.is_relative_to(root): + raise RuntimeError('Refusing to remove a path outside conversion workspace') + shutil.rmtree(target) + +def download(url,expected,cache,report): + cache.mkdir(parents=True,exist_ok=True) + archive=cache/url.rsplit('/',1)[1] + if not archive.is_file() or digest(archive)!=expected: + report('Downloading '+archive.name) + temp=archive.with_suffix('.part') + request=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 Skate3RustEngine-Setup/1.0'}) + with urllib.request.urlopen(request,timeout=60) as response,temp.open('wb') as output: + shutil.copyfileobj(response,output,1024*1024) + if digest(temp)!=expected:raise RuntimeError('Download checksum mismatch: '+archive.name) + temp.replace(archive) + return archive + +def unpack_zip(archive,destination): + destination=destination.resolve() + with zipfile.ZipFile(archive) as z: + for info in z.infolist(): + target=(destination/info.filename).resolve() + if not target.is_relative_to(destination):raise RuntimeError('Unsafe tool archive path') + if (info.external_attr>>16)&0o170000==0o120000:raise RuntimeError('Tool archive contains a symbolic link') + z.extractall(destination) + +def dependency(cache,name,url,sha,report): + folder=cache/name + marker=folder/'.complete' + if not marker.is_file(): + unpack_zip(download(url,sha,cache,report),folder) + marker.write_text(sha) + executable=next(folder.rglob(name+'.exe'),None) + if executable is None:raise RuntimeError('Missing downloaded tool: '+name) + return executable + +def run(args,log,report): + report('Running '+Path(args[0]).name) + kwargs={'creationflags':subprocess.CREATE_NO_WINDOW} if os.name=='nt' else {} + external=os.name=='nt' and getattr(sys,'frozen',False) and Path(args[0]).resolve()!=Path(sys.executable).resolve() + if external: + # PyInstaller's DLL directory is inherited by children. Blender and + # the game must load their own libraries, not the setup bundle's. + import ctypes + ctypes.windll.kernel32.SetDllDirectoryW(None) + env=os.environ.copy() + bundle=Path(sys._MEIPASS).resolve() + env['PATH']=os.pathsep.join(p for p in env.get('PATH','').split(os.pathsep) + if p and not Path(p).resolve().is_relative_to(bundle)) + kwargs['env']=env + try: + child=subprocess.Popen([str(a) for a in args],stdout=subprocess.PIPE,stderr=subprocess.STDOUT, + text=True,encoding='utf-8',errors='replace',**kwargs) + finally: + if external:ctypes.windll.kernel32.SetDllDirectoryW(sys._MEIPASS) + with child as process: + for line in process.stdout: + log.write(line);log.flush() + if line.strip():report(line.strip()[-180:]) + if process.wait():raise RuntimeError('Conversion failed. See '+str(log.name)) + +def task(script,*args): + if getattr(sys,'frozen',False):return [sys.executable,'--task',str(script),*map(str,args)] + return [sys.executable,str(script),*map(str,args)] + +def extract(archive,destination,entries=None): + data=BigArchive(archive) + data.extract_entries(data.entries if entries is None else [e for e in data.entries if entries(e)],destination) + return data + +def convert_map(archive,work,maps,stage,blender,game_exe,log,report): + map_tools=TOOLS/'vendor/university/tools/vanilla_map_extraction/tools' + sys.path.insert(0,str(map_tools)) + from prepare_hawaiian_dream import prepare + from prepare_university import EXCLUDED_NORMAL_TEXTURE_IDS + from build_retail_collision_archive import build_archive + from embed_retail_collision_archive import embed_archive + district=archive.stem.removeprefix('world') + label=district.removeprefix('DIST_') + district_work=work/district + extract(archive,district_work/'raw') + stream=district_work/'raw/data/content/world/stream'/district + if not stream.is_dir():raise RuntimeError('Missing district stream '+str(stream)) + manifest_path=prepare(stream_directory=stream,output_root=district_work/'intermediate', + utt_root=TOOLS/'vendor/utt',district_name=district,map_name=label, + package_name='Skate 3 owned disc',cache_format='skate3-rust-map-v1', + texture_stream_names=('Tex',),excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS) + raw_package=district_work/(label+'.skate') + run([blender,'--background','--factory-startup','--python-exit-code','1','--python', + TOOLS/'asset_pipeline/build_map.py','--',TOOLS,manifest_path,district_work/'map.blend',raw_package,district],log,report) + collision=district_work/'collision.rwcmset' + build_archive(manifest_path,collision) + final=maps/(label+'.skate') + embed_archive(raw_package,collision,final) + run([game_exe,'--assets',stage/'assets','--map',final,'--check-assets'],log,report) + entry={'name':label,'path':'maps/'+final.name,'sha256':digest(final)} + remove_intermediate(district_work,work) + return entry + + +def install(iso,base,game_exe,report,game_root=None): + base=base.resolve();base.mkdir(parents=True,exist_ok=True) + lock=base/'setup.lock' + try:fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY) + except FileExistsError:raise RuntimeError('Setup is already running, or a previous setup was interrupted. Close it before retrying; an abandoned setup.lock can be removed from '+str(base)) + os.close(fd) + try: + install_id=uuid.uuid4().hex + stage=base/'installations'/install_id + stage.mkdir(parents=True) + private=stage/'assets/private';private.mkdir(parents=True) + maps=stage/'maps';maps.mkdir() + work=stage/'conversion';work.mkdir() + with (stage/'setup.log').open('w',encoding='utf-8') as log: + if game_root is None: + iso=iso.resolve() + if not iso.is_file() or iso.suffix.lower()!='.iso':raise RuntimeError('Select an Xbox 360 Skate 3 ISO') + extractor=dependency(base/'tools','extract-xiso',XISO_URL,XISO_SHA,report) + game_root=work/'disc' + run([extractor,'-x',iso,'-d',game_root],log,report) + else:game_root=game_root.resolve() + for required in ['default.xex','data/big/miscload.big','data/big/miscboot.big','data/big/db.big', + 'data/content/createacharacter.big','data/content/worldDIST_University.big']: + if not (game_root/required).is_file():raise RuntimeError('This is not a supported Skate 3 disc: missing '+required) + report('Extracting animation banks, graphs and gameplay inputs') + stock=private/'stock' + extract(game_root/'data/big/miscload.big',stock) + 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' + 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=(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') + report('Preparing the skater model and textures') + manifest=json.loads((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()) + extract(game_root/'data/content/createacharacter.big',stock,lambda e:e.path.lower() in needed) + character=work/'character' + run(task(TOOLS/'extract_default_skater.py','--owned-data-root',stock,'--work-root',character, + '--private-root',private/'default_skater','--utt-root',TOOLS/'vendor/utt'),log,report) + # Blender is a portable conversion dependency, downloaded from its + # publisher and hash checked. It is never installed system-wide. + blender=dependency(base/'tools','blender',BLENDER_URL,BLENDER_SHA,report) + blend=character/'skater.blend' + run([blender,'--background','--factory-startup','--python-exit-code','1', + '--python',TOOLS/'vendor/skate3_anim/blender_rx2_abin_export.py','--', + '--abin',stock/'data/anim/OnBoard.abin','--rx2',character/'selected/models', + '--clip','R_IDLE_HCOM_000','--output',blend],log,report) + run([blender,'--background','--factory-startup','--python-exit-code','1',blend,'--python', + TOOLS/'asset_pipeline/finish_character.py','--',TOOLS,character,private],log,report) + 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') + report('Validating skater, input and animation data') + run([game_exe,'--assets',stage/'assets','--test-world','--check-assets'],log,report) + archives=list((game_root/'data/content').glob('worldDIST_*.big')) + archives.sort(key=lambda p:(p.stem!='worldDIST_University',p.name.lower())) + catalog=[] + for number,archive in enumerate(archives,1): + report(f'Converting map {number}/{len(archives)}: {archive.stem}') + catalog.append(convert_map(archive,work,maps,stage,blender,game_exe,log,report)) + if not any(m['name']=='University' for m in catalog):raise RuntimeError('University was not converted') + report('Validating installed runtime inputs') + run([game_exe,'--assets',stage/'assets','--test-world','--check-assets'],log,report) + settings=stage/'settings';settings.mkdir() + (settings/'default-map.json').write_text(json.dumps('maps/University.skate'),encoding='utf-8') + (stage/'maps.json').write_text(json.dumps(catalog,indent=2),encoding='utf-8') + remove_intermediate(work,stage) + # Publish only after every conversion and the runtime's own load succeeds. + marker=base/'installation.json.new' + marker.write_text(json.dumps({'version':1,'directory':'installations/'+install_id}),encoding='utf-8') + marker.replace(base/'installation.json') + report('Setup complete') + return stage + finally:lock.unlink(missing_ok=True) diff --git a/tools/asset_pipeline/names.txt b/tools/asset_pipeline/names.txt new file mode 100644 index 00000000..0a7b7692 --- /dev/null +++ b/tools/asset_pipeline/names.txt @@ -0,0 +1,2488 @@ +A +ABCDEFg +AGIntent +AISkeletonScalar +ANIMTRANSZ +ARM +Absent +AbsoluteMinHeight +AbsorptionFootForceRearScalar +AbsorptionFootForceScalar +AccelerationClampMax +AccelerationClampMin +ActionGraph +AddRunoutAttribs +AdjustOnPrepare +AirBodyTweak +Air_MaxUpVectAngleDelta +AlignAxisEuler +AlignOnGroundTorqueVelScalar +AlignWithVel +AllowedToTrick +AmplitudeCurve +AmplitudeMax +AmplitudeMin +AmplitudeTopSkaterSpeed +AmplitudeVsSpeed +AmplitudeVsTime +AngleRange50_50 +AngleRange5_0 +AngleRangeBoardSlide +AngleRangeTipSlide +AngleYAssist50_50 +AngleYAssist5_0 +AngleYAssistBoardSlide +AngleYAssistTipSlide +AngularDispMin +AngularDrag +AngularForceScalar +AngularSpeedDamping +AngularStrMin +Angular_Hard_Damping +Angular_Hard_Displacement +Angular_Hard_Strength +AnimCommittedHandPlant +AnimEndCOMX +AnimEndCOMY +AnimEndCOMZ +AnimPhysBlendSec +AnimSkateboard +AnimTime +AnimTransX +AnimTransY +AnimTransZ +AnimationNoiseScalar +AnimationTurnDirFilter +AnimationTurnDirMax +AnimationTurnGraphX +AnimationTurnLeanFilter +AnimationTurnLeanFilterSlow +AnimationTurnMaxLean +AnimationTurnPhysicsWeight +AnimationTurnSpeedGraph +AnimationTurnWobbleScalar +AntiFlipTorqueX +AntiFlipTorqueZ +Antic +AnticAngle +AnticBrd +AnticMag +AnticStrength +Anticipating +Anticipation.AnticCyc.360PopShuvitAnticCycle +Anticipation.AnticInto.AnticTail +ApplyingBodyTilt +AttachIntent +AudibleFootStepStrength +AutoLineupSpeed +AutoPumpAngle +AutoPumpMag +AutoPushEnabled +AutoTurnVsAngle +AvgVelY +AvoidanceCollisionOffset +AvoidanceEaseOut +AvoidanceElevationSmoothing +AvoidanceHeadingSmoothing +AvoidanceLookahead +B +BACK +BF_ +BF_BS_50_50 +BF_BS_5_O +BF_BS_CROOK +BF_BS_FEEBLE +BF_BS_NOSEGRIND +BF_BS_OVERCROOK +BF_BS_OVERWILLY +BF_BS_SALAD +BF_BS_SMITH +BF_BS_WILLY +BF_FS_50_50 +BF_FS_5_O +BF_FS_CROOK +BF_FS_FEEBLE +BF_FS_NOSEGRIND +BF_FS_OVERCROOK +BF_FS_OVERWILLY +BF_FS_SALAD +BF_FS_SMITH +BF_FS_WILLY +BMAT +BOARD_BACKWARDS +BOARD_BACKWARDS_IK +BOTH +BOUNDARY_RAMP +BS +BSPowerslide +BSRevert +BS_50_50 +BS_5_O +BS_BLUNT +BS_BOARD +BS_CROOK +BS_DARKSLIDE +BS_FEEBLE +BS_LIP +BS_NOSEBLUNT +BS_NOSEGRIND +BS_NOSESLIDE +BS_OVERCROOK +BS_OVERWILLY +BS_SALAD +BS_SMITH +BS_TAILSLIDE +BS_Varial +BS_WILLY +BTREE_RIDING +BUMPX +BUMPY +B_BUMP +B_FAKIE_CHANNEL +B_GSTR_AIRGUITAR +B_GSTR_AIRPLANE +B_GSTR_BICEPFLEX +B_GSTR_BOXING +B_GSTR_BRUCE_LEE +B_GSTR_CHECKTIME +B_GSTR_DBLE_GUN +B_GSTR_DEVIL +B_GSTR_DUNNO +B_GSTR_FINGERWAG +B_GSTR_FISTS +B_GSTR_FLIPTABLE +B_GSTR_FONZ_EH +B_GSTR_FREEDOM +B_GSTR_FU_FIST +B_GSTR_GETAWAY +B_GSTR_GETOUTTAHERE +B_GSTR_HANDCUFFS +B_GSTR_HIGHPUMP +B_GSTR_LOWPUMP +B_GSTR_PEACE +B_GSTR_POINT +B_GSTR_POINT_SKY +B_GSTR_PREWIND +B_GSTR_RAISEROOF +B_GSTR_SHAKA +B_GSTR_SHRUG +B_GSTR_SNAP +B_GSTR_SOULARCH +B_GSTR_SPOCK +B_GSTR_SURFSUP +B_GSTR_SWINGHIGH +B_GSTR_SWINGLOW +B_GSTR_THMB_DWN +B_GSTR_THROWARMS +B_GSTR_WINGS +B_GSTR_YARDSALE +B_MONGO_PUSH_CYC1 +B_MONGO_PUSH_CYC2 +B_MONGO_PUSH_INTO +B_N_INWARDHEELFLIP_A +B_OBAIR_BODYTWEAK_BR_CYC +B_OBAIR_BODYTWEAK_BR_INTO +B_OBAIR_BODYTWEAK_NB_CYC +B_OBAIR_BODYTWEAK_NB_INTO +B_PUMP +B_PUSH_CYC1 +B_PUSH_CYC2 +B_PUSH_INTO +B_PUSH_MONGO_OUT +B_PUSH_OUT +B_SWITCH +Back +BackFlip +Balance +Behavior +BipedAnimTime +BipedBoardOnGround +BipedCadence +BipedSpeed +BipedSpinAngle +BipedStartAngle +BlendPoint1 +BlendPoint2 +BlendPoint3 +BlendShot1 +BlendShot2 +BlendShot3 +BlendType +BlendValue +Board +BoardAdjust +BoardAdjustAngle +BoardAdjustDown +BoardAdjustLeft +BoardAdjustMag +BoardAdjustRight +BoardAdjustUp +BodyAdjustX +BodyAdjustZ +BodyFlipMinGrabTimeFraction +BodyFlippingSignal +BodySpin +BodySpinInputFilter +BrCycAnim +BrIntoAnim +Brake +BrakeTiltAngle +BrakingScalar +BreakOutOfPush +C:/skater.glb +CARGO_CFG_TARGET_OS +CARGO_MANIFEST_DIR +CYC +CadenceEndPercent +CadenceStartPercent +CameraChooseShot +CameraSubjectAcceleration +CameraSubjectSpeed +CameraSubjectSpeedY +CanBipedLand +CanEnterSlide +CanLandOnBoard +CannonBall +CapsuleLengthScalar +CapsuleRadiusScalar +Carving +CharacterGesture +ChooseRandomLanding +ChristAir +ClearTrickAttr +CoffinAcceleration +CollectionName +CollisionDriveScalar +CollisionEffectTime +CollisionForceScalar +CollisionInterpolationDuration +CollisionSoftDisp +CollisionSoftStrength +CollisionTorqueFadeoff +CollisionTorqueMaxTime +CollisionTorqueVsAngle +ComVelCompare +Compression +CompressionDeckScalar +CompressionGroundScalar +CompressionVsDeckAngle +CompressionVsGroundAngle +ComputeFirstPushStrength +ComputeRepushDeadline +ComputeTargetCoefsFromSpeedAndStrength +Condition +ConeAngleX +ConeAngleXSecondPass +ConeAngleZ +ConeAngleZSecondPass +ConeAngleZVsSpeed +ControlAirLegExtension +ControlGrindCrouch +ControlledWipeout +CreateAttribute +CreateConstMGIntent +CreateGrindAttributes +CreateMGIntentFromAGIntent +CreateMGTimeIntentFromAGIntent +CreateSlide +CreateTrickIntentFromGesture +Crouch +CrouchMax +CrouchMin +CrouchStartSpeedMaxAdjust +Crouching +CurrentGrabType +CurrentState +Curve +DISTTOCOG +DPadD +DPadL +DPadR +DPadU +D_Scalar_Limit +Damping +DangerZone +DarkCatch +DataFPS +DeadzoneSizeSmoothing +Deck +DeckAccAIScalar +DeckAccBodyFlipScalar +DeckAccPlayerScalar +DeckAccSkitchScalar +DeckAngleUsageVsSpeed +DeckAngularDrag +DeckBB +DeckBBYOffset +DeckBackEndAngle +DeckBackEndSize +DeckCenterToTruck +DeckDynamicFriction +DeckEnableDeckVolumeCollisions +DeckEnableEndVolumeCollisions +DeckEndCapsules +DeckForceYOffset +DeckFrontEndAngle +DeckFrontEndSize +DeckMass +DeckMidLength +DeckRestitution +DeckStaticFriction +DeckThickness +DeckWidth +DeltaPenumbra +DeltaUmbra +DerivativeBodySpinVsTime +DisableDismount +DisableFeetTargetIK +DisablePushBrake +DisableTricks +DisallowPumping +Dismount +DisplacementLimit +DistToCog +DistToEdge +DistanceForBoardReturn +DistanceForMountedReturn +DistanceToHideSkateboard +DoInverseInertia +DoInverseMass +DoLandOnBoard +DontAlignAnglePhysicsAir +DontMirrorTrick +DriftFactorX +DriftFactorZ +DriftMaxSpeed +Drive +DriveStrengthLocal +DriveStrengthRootLocal +DropInLock +DropInRelease +DutchInterpolationSpeed +DutchMultiplier +DynamicDriveWeightVelCollision +DynamicDriveWeightVelControlled +DynamicSpeedDamping +DynamicSpeedMaxDelta +DynamicSpeedMaxDeltaGraphZ +DynamicSpeedMaxDeltaXScale +DynamicUpVectorDamping +DynamicUpVsGroundY +EA::Reflection::Bool +EA::Reflection::Float +EA::Reflection::Int32 +EA::Reflection::Text +EA::Reflection::UInt32 +EA::Reflection::UInt8 +Easy +EasyBodySpins +EnableCollision +EnableWipeoutGestures +EndGesture +EndShimmy +EnforceSwingFree +EnforceTwistFree +EnoughDistToObstacle +EnteringCoffin +ExitGrind +ExpireInTime +ExtraSideDamping +FOLLOW_ANIMATION_DATA +FORCE_ANIM_SKATEBOARD +FORCE_PHYSICS_SKATEBOARD +FOREARM +FRONT +FS +FS360PopShuvit +FSPopShuvit +FSPowerslide +FSRevert +FS_50_50 +FS_5_O +FS_BLUNT +FS_BOARD +FS_CROOK +FS_DARKSLIDE +FS_FEEBLE +FS_LIP +FS_NOSEBLUNT +FS_NOSEGRIND +FS_NOSESLIDE +FS_OVERCROOK +FS_OVERWILLY +FS_SALAD +FS_SMITH +FS_TAILSLIDE +FS_Varial +FS_WILLY +FXBlurShot +FXBlurTransition +FXSubjectOpacity +FaceLocalOffset +FaceRadius +FakieHeadChannel +FakieTurn +FastAccelerationClampMax +FastSpeedClamp +FeetIKMaxBlendDelta +FeetIKMaxBlendDeltaOut +FeetRelativeHeightOnDeck +FeetRelativeHeightWipeout +FilterMotionGraphIntent +FingerFlip +Fingerflip +FixLeftFoot +FixRightFoot +Fixture +Flat +FlipAxisAdjustment +FlipBodySpinScalar +FlipMaxSpeed +FlipScalar +FlipSpeedSmoothingFactor +FlipTrick +FootBrakeForce +FootForceOffset +FootJump +FootOnDeckPadding +FootPlanting +FootVolumeYOffset +ForceBrake +ForcePhysics +ForceYOffset +FramesAtStartOfAirToIgnoreDangerZone +FramesForGrindAirAssist +FramingLensLength +FramingPitch +FramingRoll +FramingYaw +FreeCamElevationOffset +FreeCamElevationSpeed +FreeCamHeadingOffset +FreeCamHeadingSpeed +Freefall +FreezingEnergy +FrequencyCurve +FrequencyMax +FrequencyMin +FrequencyTopSkaterSpeed +FrequencyVsSpeed +FrequencyVsTime +FrictionNormal +FrictionRagdoll +FrictionRagdollHead +FrictionVsSpeedNew +FrictionVsSpeed_Manual +FrictionVsSpeed_NoInput +FrictionVsTime +Friction_MaxSpeedChange +FromAntic +FrontFlip +FrontsideAngle +FullExtension +GAME_VERIFY_OK +GP_AFace +GP_BFace +GP_DDPad +GP_LBumper +GP_LDPad +GP_LStickIn +GP_LStickX +GP_LStickY +GP_LTrigger +GP_RBumper +GP_RDPad +GP_RStickIn +GP_RStickX +GP_RStickY +GP_RTrigger +GP_UDPad +GP_XFace +GP_YFace +GeneralAmount +GeneralScalar +GestureBoth +GestureDownHeld +GestureDownStart +GestureLeft +GestureLeftHeld +GestureLeftStart +GestureRight +GestureRightHeld +GestureRightStart +GestureSpeed +GestureType +GestureUpHeld +GestureUpStart +GrabBoxOffset +GrabBoxSize +GrabBoxSizeGrabbing +GrabSlide +GrabSplineAngleLimit +GrabSplineAngleLimitGrabbing +GrabSplineEndExclusion +GrabSplineMaxAngleToHorizontal +GrabSplineMaxAngleToHorizontalGrabbing +GrabWorld +Grabbing +Graph +Gravity +GreaterEqual +GreaterThan +Grind5050 +Grind5_O +GrindAdjustMaxAngle +GrindBalanceX +GrindControlFade +GrindFacingBackwards +GrindFacingForwards +GrindInclineAngle +GrindLandingMaxAngle +GrindLandingVelScalar +GrindLockDist +GrindLockLedgeLowSideScalar +GrindLockLedgeLowSideScalar2 +GrindLockLedgeScalar +GrindLockLedgeScalar2 +GrindMaxSpeedDownOntoGrind +GrindMaxSpeedSqrIntoGrind +GrindMaxSpeedSqrIntoLedge +GrindNose +GrindOffset +GrindPenaltyVsDistToGrind +GrindTipScalar +GrindTrick +GroinLocalOffset +GroinRadius +GroundNormalSmoothing +GroundPlaneMaxAngle +GroundPlaneMaxDist +GroundSlopeType +GroundVMaxBlendDelta +GroundVectorBlendGraph +GstrDistToCog +HAND +HIPS +HStr_Vel_B +HalfHeightScalar +HandBusy +Handler +HardTurn +HardTurnCrouch +HardTurnIncrease +Hardcore +Hardflip +HasAGIntent +HasAnimAttribute +HasFilteredMotionGraphIntent +HasGestureIntent +HasIntent +HasLandingType +HasTiltToLargeForPreland +HasTweak +Hash_138976EAF99DF926 +Hash_14A2DDDD0A35C267 +Hash_1B3E9F9C836D287D +Hash_1BC908CDB3520A8E +Hash_1E6BDF62043784FC +Hash_24D5AA7F214A901C +Hash_28446A9E25DCC04C +Hash_2AD93E5ABA231D2 +Hash_2B913C786BEFF4CD +Hash_3097A69281990652 +Hash_31309236050A8F09 +Hash_372DF5DB27AF1C79 +Hash_3974F0228331EB22 +Hash_40B2B3C1A87A4D76 +Hash_472174920C68FBE3 +Hash_4F08D9BAE6831524 +Hash_501D5581043D7D3C +Hash_5548109D7B0CB70C +Hash_5B57F2CCCCEEF430 +Hash_6009FD75F9EDC436 +Hash_6B93C51256A30FB4 +Hash_6C6CCD16A0A438AD +Hash_703829BD711E54DE +Hash_7209DCFDF3015EBF +Hash_7379F8AB250BF611 +Hash_77AFCE78FE1206CA +Hash_7E6B3A99C0A33ABC +Hash_84CB2F459F3FC811 +Hash_9047531918285FC3 +Hash_91282E2CC4252731 +Hash_9626703A9939FE36 +Hash_9D1AEE3D7D8A4A7 +Hash_AA79C93673533C5B +Hash_AB0D9EAEBFC584E9 +Hash_B2B1170AFFC8AC69 +Hash_B59D493B3DB372C8 +Hash_B65276A8CC7FB760 +Hash_BD3E4F132848A588 +Hash_BE3F74F978D777E5 +Hash_BEA30B5DC6AFF26A +Hash_C313093118C7A83F +Hash_C4B564DFD233CF04 +Hash_CC890A3BDCF6290A +Hash_CE45C724B30F9134 +Hash_D70C95EDC16A7DFC +Hash_D7652B4B19BB4747 +Hash_D77AFD320B6241C5 +Hash_D7C6855B7814D048 +Hash_D978550D6DB4E6F7 +Hash_DE162591FD9D91D7 +Hash_DF759B46440F16E9 +Hash_E1B89BB09D725EEC +Hash_E2D9D0FAAB8CB7F3 +Hash_F2473E9125079F0 +Hash_F8CBC0F5FEF2240E +Hash_FA4CDBAE0DFD1FAD +Hash_FB5A1024D47298A1 +HeadingAdjustFactor +HeadingAdjustMaxSpeed +HeadingAdjustVsSlope +HeadingAdjustVsSpeed +HeadingChangeResponseSpeed +Heelflip +HeelflipHold +HippyJumping +HoldPattern +HoldingBlendDown +HoldingBlendUp +HoldingIntegralBlend +HoldingMaxAcceleration +HoldingMinVal +Hook +HookSoftDmp +HookSoftDsp +HookSoftStr +HorizAlignAxis +IA_BODYSPIN_OLLIE_BS_0_N +IA_BODYSPIN_OLLIE_FS_0_N +IKBoneOnDeckBBox +IKMaxAngle +IKMinAngle +IKOffPadding +IKOnPadding +INTO +InCandidateSlidingState +InCoffin +InManualZone +InParentStateForTime +InStateForTime +InTime +InTimeTag +InTimeWindow +InclineAngle +InclineAngleAbs +InertiaMultFactor +InertiaMultiplyType +InitMovingObjects +InitPush +InputQuicknessBlendVal +InputQuicknessMax +InwardHeelflip +IsAirOffboard +IsAnimInterpToEdge +IsAnticipating +IsBipedCommittedToMotion +IsBipedGroundThin +IsBodyFlipping +IsBrokenBoneSlowMo +IsBumped +IsCameraInBadCollision +IsCameraTypeActive +IsCrouchedEnoughForBlendToGrabCycle +IsDark +IsDarkCatchRequested +IsDoingTrick +IsDoneWipingOut +IsDroppingIn +IsDroppingSkateboard +IsEnteringSkitch +IsFixedInAir +IsForceDownLFoot +IsForceDownRFoot +IsGrabbingObject +IsGrindLedgeLeft +IsGrindLedgeRight +IsGrinding +IsGroundMount +IsHandPlanting +IsHippyHurdling +IsHoldingSkateboard +IsInBipedAir +IsInDebugAnimationsMode +IsInLocomotion +IsInManual +IsInObserverMode +IsInOnBoardAir +IsInVolume +IsInWater +IsInWorldPainterRegion +IsLanding +IsLandingOnBoard +IsManualing +IsMirrored +IsMuteLFoot +IsMuteRFoot +IsOffboard +IsPhysicsWiping +IsPlayerSkateboardOnGround +IsPowerSliding +IsPreparingToJump +IsPreviousShot +IsProSkater +IsPushOffEnabled +IsReentry +IsRetrievingSkateboard +IsRidingFakie +IsRidingGoofy +IsRidingSwitch +IsSkitching +IsStandingOnMovingObject +IsStumbling +IsTakeDownByBoard +IsTricking +IsUnderflipRequested +IsWipeoutBodyTweak +IsWipeoutPushOff +IsWipeoutPushOffImpulse +IsWipingOut +JOINT_HIPS_LEFT_LEG +JOINT_HIPS_RIGHT_LEG +JOINT_HIPS_SPINE +JOINT_LEFT_ARM_FOREARM +JOINT_LEFT_FOOT_TOE_BASE +JOINT_LEFT_FOREARM_HAND +JOINT_LEFT_LEG_FOOT +JOINT_LEFT_SHOULDER_ARM +JOINT_LEFT_UPLEG_LEG +JOINT_NECK_NECK1 +JOINT_RIGHT_ARM_FOREARM +JOINT_RIGHT_FOOT_TOE_BASE +JOINT_RIGHT_FOREARM_HAND +JOINT_RIGHT_LEG_FOOT +JOINT_RIGHT_SHOULDER_ARM +JOINT_RIGHT_UPLEG_LEG +JOINT_SPINE1_SPINE2 +JOINT_SPINE2_SPINE3 +JOINT_SPINE3_LEFT_SHOULDER +JOINT_SPINE3_NECK +JOINT_SPINE3_RIGHT_SHOULDER +JOINT_SPINE_SPINE1 +JudoKick +JuiceHook +JumpAboutToTakeOff +JumpAdjustXFactor +JumpAdjustZFactor +JumpHeight +JumpHeightOverride +JumpHeightOverrideEnabled +JumpInProgress +JumpInto +JumpMaxHeight +JumpMinHeight +JumpSpeedScalar +JumpSpeedScalarVsAngle +JumpYBonusMax +JumpYScalarVsGroundNormalY +KeepDefaultAnim +KickTurn +KickTurnSteering +Kickflip +KickflipHold +KickoutDismount +LBumper +LEFT +LEFTARM +LEFTFOOT +LEFTFOREARM +LEFTHAND +LEFTHAND_REPARENTED +LEFTSHOULDER +LEFTTOEBASE +LEFTTOEBASE_REPARENTED +LStick +LStickD +LStickL +LStickR +LStickR-LStickL +LStickU +LStickU-LStickD +LStr_Vel_B +LTrigger +L_BS_Shuvit +L_B_Heelflip +L_B_Kickflip +L_FS_Shuvit +L_F_Heelflip +L_F_Kickflip +Landing +LandingAdjustCoffinBounceBaseHeight +LandingAdjustCoffinBounceFramesToBlendAway +LandingAdjustCoffinBounceHeight +LandingAdjustCoffinBounceMaxVel +LandingAdjustCoffinBounceTime +LandingAdjustDesiredCom +LandingAdjustGrindBlend +LandingAdjustGrindBlendTowardsAnimTargetSpeed +LandingAdjustGrindK1 +LandingAdjustGrindK2 +LandingAdjustGrindStartUsingAnimTargetAfterTime +LandingAdjustGroundK1 +LandingAdjustGroundK2 +LandingAdjustGroundMinCompressionTime +LandingAdjustManualBlend +LandingAdjustManualK1 +LandingAdjustManualK2 +LandingAdjustMaxVel +LandingAdjustMinYPos +LandingComScalarVsSlope +LandingDistanceScalarVsNormalY +LandingForceScalarVsDPVelNorm +LandingForceTime +LandingInclineAngle +LandingOnDeckEffectScalar +LandingOnDeckOffset +LandingSketchySideSpeed +LandingSketchyTwistSpin +LandingSpeedScalarVsGroundNormalY +LandingTimeBonus +Laserflip +LastState +LateTrick +LaunchInclineAngle +Lean +LeavingCoffin +LeftAirGrab +LeftGround +LeftGroundGrab +LeftHand_Reparented +LeftPush +LeftSlide +LeftSlideStart +LeftToeBase +LeftToeBase_Reparented +Left_WheelBack +Left_WheelFront +LegLengthOnLanding +LessEqual +LessThan +LinearDispMin +LinearDrag +LinearStrMin +LivingWorldPushForce +LocoState +LongSkitchIntoReachTime +MGIntent +MGIntentAngle +MGIntentMag +MagScale +MaintainShove +Manual +ManualBrake +ManualOutTimerIsActive +ManualScalar_D +ManualScalar_I +ManualScalar_P +ManualSpinBlendSpeed +ManualSpinDrag +ManualSpinDragNormal +ManualSpinDragSpeed +MassOfSkeleton +MatchAirTime +MatchCadence +MatchTwistAndLean +Math::Vector3 +MaxAllowedGroundNormalFromUp +MaxAmplitude +MaxAngleDeltaPerFrame +MaxAngleDiff +MaxAutoBodySpinSpeed +MaxCrouchFromDeckAngle +MaxDeadzoneSize +MaxDeadzoneSpeed +MaxDeckZAxisYForAnimatedDeck +MaxDeltaForAutoVsTime +MaxDeltaHeightAllowedPerFrame +MaxDeltaOppositeDirection +MaxDeltaVsTime +MaxDist50_50 +MaxDist5_0 +MaxDistBoardSlide +MaxDistTipSlide +MaxDistance +MaxDropDistance +MaxFrequency +MaxGravityAcceleration +MaxHeadingAdjustVsUpY +MaxHeightVsSpeed +MaxLandingForce +MaxLegAngleError +MaxOffsetDeltaPerFrame +MaxOffsetDist +MaxPitch +MaxPushDVEnd +MaxPushDVStart +MaxPushableSpeed +MaxPushableSpeed_CameraDelta +MaxSmoothing +MaxSpeedForCorrection +MaxSpinSpeed +MaxTiltAngle +MaxTime +MaxTimeWallRidingForFootForce +MaxTrajectoryAdjust +MaxVelDelta +MinAutoBodySpeed +MinChangeInCOMBeforePumping +MinCrouchVsGroundAngle +MinDeadzoneSpeed +MinDerivativeScalar +MinDistance +MinDistanceDuringWipeout +MinForce +MinHeightVsSpeed +MinJump +MinNormalDeltaForSecondPass +MinScalar +MinSpeedForCorrection +MinTargetHeadingVel +MinTestDistance +MinTimeAfterApexForTransition +MinTimeForTrajectoryToBeValid +MinTimeToRetrieveBoardWhileMounting +MinTrajectorySize +MinWheelsToUseGroundVector +Missing +MongoPushFootToFar +MongoPushToAntic +MonitorUnderflip +MotionGraph +MotorEnabled +MotorTopSpeed +MovingObject +N90Nose +N90Tail +N90_360Flip +N90_360Hardflip +N90_360InwardHeelflip +N90_360PopShuvit +N90_FS360PopShuvit +N90_FSPopShuvit +N90_Hardflip +N90_Heelflip +N90_InwardHeelflip +N90_Kickflip +N90_Laserflip +N90_N_360Flip +N90_N_360Hardflip +N90_N_360InwardHeelflip +N90_N_360PopShuvit +N90_N_FS360PopShuvit +N90_N_FSPopShuvit +N90_N_Hardflip +N90_N_Heelflip +N90_N_InwardHeelflip +N90_N_Kickflip +N90_N_Laserflip +N90_N_PopShuvit +N90_N_VarialHeelflip +N90_N_VarialKickflip +N90_Nollie +N90_Ollie +N90_PopShuvit +N90_VarialHeelflip +N90_VarialKickflip +NB_RUN_FWD_CYC +NB_SPRINT_FWD_CYC +NB_WALK_FWD_CYC +NOLLIE_360FLIP_D_HIGH_G +N_360Flip +N_360Hardflip +N_360InwardHeelflip +N_360PopShuvit +N_FS360PopShuvit +N_FSPopShuvit +N_Hardflip +N_Heelflip +N_HeelflipHold +N_InwardHeelflip +N_Kickflip +N_KickflipHold +N_Laserflip +N_PopShuvit +N_VarialHeelflip +N_VarialKickflip +NaturalAirMaxDist +NaturalAirMinDist +NaturalAirOffVertsScalar +NaturalAirTime +NbCycAnim +NbIntoAnim +NegativeGeneralAmount +NewAutoPump +NewDarkCatch +NewDismount +NewHandPlant +NewLeftAirGrab +NewPush +NewRightAirGrab +NewToggleOffBoardState +NoDangerZone +NoFootAirWalk +NoInput +NoInputTime +NoiseVsSpeed +Nollie +Non-finite +Normal +Nose +NoseManual +NotEqual +NumFramesToTest +NumTicksPatternNotInRangeBeforeCulling +OBTimeToLand +OBTrajTime +OB_Air +OB_AirBodyTweakX +OB_AirBodyTweakY +OB_BipedStickMag +OB_BipedWorldX +OB_BipedWorldZ +OB_Dismount +OB_DoAirBodyTweak +OB_DropBoard +OB_DropIn +OB_DroppingBoard +OB_HoldBoard +OB_Jump +OB_JumpX +OB_JumpZ +OB_LookAtX +OB_LookAtY +OB_Mag +OB_Mount +OB_Mounting +OB_OBJECTMVX +OB_ObjectMvRot +OB_ObjectMvX +OB_ObjectMvZ +OB_ReleaseBoard +OB_RetrieveBoard +OB_RetrievingBoard +OB_Sprint +OB_StandingJump +OB_ThrowBoard +OB_Toggle +OB_Traj +OB_Turn +OB_Unmounting +OFB_THROW_0 +OFB_THROW_90 +OUT +OUT_DIR +Off +OffBoard +OffBoard.abin +OffboardBodyTweakBlend +OkToDoTrickOnStairs +Ollie +On +OnBoard +OnBoard.abin +OneFootAir +OneFootIntent +OneShotDecay +OneShotFrequency +OneShotMagnitude +OneShotMaxVelocity +OneShotMinVelocity +OptionAnchor +OptionAvoidanceOverride +OptionCollisionType +OptionCompassNorth +OptionFollowSubjectInAir +OptionMirrorForStance +OptionSnapToReferencePoint +OptionUseDropPredictor +OptionUseFreeCamStick +OptionUsePreviousShot +OrientationOffset +OutDistanceX +OutDistanceY +OverideNextAnimTransitionHook +PAUSED +PHYS_TPOSE +PLAYING +POSTURE_BUFF_POSE +POSTURE_SLOUCH_POSE +POSTURE_STIFF_POSE +PRECOND +PUMP0 +PUMP1 +PUMP2 +PUMP3 +PUMP4 +PanMinSmoothing +PanPenumbra +PanSmoothingCurve +PanUmbra +PerfectBodyFlips +PhysBodySpin +PhysFilteredState +PhysGrindGrabMinHeight +PhysGrindStabilityNudge +PhysGrindTranslation +PhysGrindUpDown +PhysToAnimFast +PhysToAnimSlow +PhysicsRequestsDismount +PhysicsSpeedAndSlopeCompare +PhysicsSpeedCompare +PhysicsWantsRunout +PhysicsWantsWipeOut +PinVsSlope +Pixel +PlayAnimation +PlayerControlledPump +PlayingLanding +PopShuvit +PosOffset +PositionDistance +PositionElevation +PositionHeading +PostIKMinYAxisVal +PostIKPadding +PowerScalar_D +PowerScalar_I +PowerScalar_P +PowerSlideDecel +PowerSlideForceYOffset +PowerSlideManualAtt +PowerSlideScalar +PowerSlideSpin +PowerSliding +PowerslideExitScalar +Powerslide_SpeedToForce +Powerslide_YawDamping +Powerslide_YawStrength +PredictedAirTime +PredictedHeightDistanceRatio +PredictedLandingHeight +PredictedMaxHeight +Prepare-Assets +PrepareJump +PrintText2D +ProSkater +ProceduralNoiseFreq +ProceduralNoiseScalar +PropBodySpinVsTime +PumpEffectDamping +PumpEffectFactor +PumpEffectFactorAbsorption +PumpVsTime +PumpVsVel +Pumping +PumpingMinAcceleration +PushContact +PushCycle +PushOut +PushScalarDecriment +PushScalarIncriment +PushScalarMin +PushSpeed +PushTricking +Pushing +QuicknessMaxDelta +QuicknessVsQuickness +RBumper +RC +REPLAY +RIDING_BOARD +RIDING_GROUND +RIDING_INPUT +RIGHT +RIGHTARM +RIGHTFOOT +RIGHTFOREARM +RIGHTHAND +RIGHTHAND_REPARENTED +RIGHTSHOULDER +RIGHTTOEBASE +RIGHTTOEBASE_REPARENTED +RIG_TPOSE +RStick +RStickD +RStickL +RStickR +RStickR-RStickL +RStickU +RStickU-RStickD +RTrigger +RWCM +RWCMSET1 +RWMaxIterations +R_ANTIC_360SHUVIT_N_0_CYC +R_ANTIC_OLLIE_N_0_CYC +R_ANTIC_OLLIE_N_0_INTO +R_IDLE_HCOM_000 +R_PUSHHSP_HSTR_MONGO_0_CYC1 +R_PUSHHSP_HSTR_N_0_CYC1 +R_PUSHHSP_LSTR_MONGO_0_CYC1 +R_PUSHHSP_LSTR_N_0_CYC1 +R_PUSHLSP_HSTR_MONGO_0_CYC1 +R_PUSHLSP_HSTR_N_0_CYC1 +R_PUSHLSP_LSTR_MONGO_0_CYC1 +R_PUSHLSP_LSTR_N_0_CYC1 +RadiusScalar +RagdollInvMassFactor +RagdollSoftDisp +RagdollSoftStrength +Rail +RampDnShallow +RampDnSteep +RampUpShallow +RampUpSteep +Random +RandomCond +RawTurn +ReferenceBoardOffset +ReferenceWeightAnchor +ReferenceWeightBoard +ReferenceWeightCOM +ReferenceWeightDampedCOM +ReferenceWeightFeet +ReferenceWeightGrind +ReferenceWeightHead +ReferenceWeightHips +ReferenceWeightTrajectory +Registered +RequiredAngleVsHeight +ResetSkaterAnimation +ResetTimeSinceKickturn +ResetToGivenStance +RestitutionNormal +RestitutionRagdoll +RestitutionRagdollHead +Resume +RetrievalSpeedCurve +RetrievalTimeVsDist +RetrieveBoard +Revert +RevertDir +RideIdle +Riding +RightAirGrab +RightGroundGrab +RightHand_Reparented +RightPush +RightSlide +RightSlideStart +RightToeBase +RightToeBase_Reparented +Right_WheelBack +Right_WheelFront +RollOnGround +RollOnGroundAxis +RollingOnGroundTorqueVelScalar +Root +Root.On +RotationMultiplier +Route +Run +RunoutDismount +SHOULDER +SKATE +SKATE3_ASSET_ROOT +SKATE3_MOTION_INVENTORY +SKATEBOARD_ROOT +SKATE_CLIMB_CAPTURE +SKATE_MAP_TEST_PATH +SKATE_OCCLUSION +SKATE_PERF_CAMERA_SWEEP +SKATE_PERF_REPORT +SKATE_RWCM_TEST_PATH +SKATE_VERIFY_REPLAY +SKCH_2H_SHIMMY_LEFT_CHANNEL +SKCH_2H_SHIMMY_RIGHT_CHANNEL +SKYB +SPINE3 +Saved +ScaleQuicknessAtSpeed +ScoreGrind +ScoreLandingDirection +ScoreLandingForce +ScoreMiddleBonus +ScoreSurface_DontAlign +ScoreSurface_Unrideable +ScoreTransition +ScoreWallrideHeight +ScoringGrabs +ScoringTrick +Selection +SelectionBrd +SetBumpCoefficients +SetDark +SetDistComToBoard +SetGrabType +SetLandingData +SetManualOutTimer +SetPushCoefs +SetScoreAugmentation +SetSpeed +SetTrickAttr +SetTrickHeight +SetTurning +SettingBodyTilt +ShotType +ShouldLeaveSlide +Shove +ShoveDirection +Shoving +SideFrictionFactor +SideFriction_Scalar +Sk8::PointGraphData8 +Sk8::PointNegGraphData4 +Sk8::PointNegGraphData8 +SkateRootCapsuleLength +SkateRootCapsuleRadius +SkateSlope +SkateboardFriction +SkateboardMassFactor +SkateboardRestitution +Skateboard_Root +SkaterIsDroppingIn +SkaterIsGrinding +SkaterIsMovingObject +SkaterIsOnRoad +SkaterIsPerformingBoneless +SkaterIsPerformingFootPlant +SkaterIsPerformingHandPlant +SkaterIsPerformingHippyHurdle +SkaterIsPerformingHippyJump +SkaterIsSkitching +SkaterIsUnderThreat +SkaterOffBoard +SkaterSkaterThresholdScalar +SkaterSkeletonScalar +SkaterWasGrinding +SkitchAntic +Slide +SlideBoard +SlideBs180 +SlideFs180 +SlideNose +SlideStart +SlideTail +SlideVsNormal +SlideVsSlope +SlideVsSpeed +SlowMotionController +SlowSpeed +SmallObjectMassThreshold +SmoothingBlendValue +SmoothingConstant +SmoothingDirection +SmoothingElevation +SmoothingMin +SmoothingPitch +SmoothingYaw +SoftestWheelPowerslideFactor +SoftestWheelPowerslideSpinFactor +SoftestWheelSideFrictionScalar +SoftestWheelStraightenOutTimeScalar +SpeedClamp +SpeedFactorMax +SpeedFactorMin +SpeedRange +SpeedResponseMaxSpeed +SpeedToAlignToGround_PhysAir +SpeedVsInput +SpeedWobbleTurnBlend +Spin +SpinCorrectiveForce +SpinInertia +SpinNoLiftScalar +SpinVsSpeed +SpinVsTime +Spot +Sprint +Square +SquishVsTimeLanding +SquishVsTimeTakeOff +StairDnShallow +StairDnSteep +StairUpShallow +StairUpSteep +Stand +Start +StartTime +StartTorqueScalar +SteeringScalar_TightTrucks +SteeringTiltBlending +SteeringVsInput +SteeringVsSpeed +SteeringVsSpeedMaxSpeed +StickMagnitudeMinToCountHeld +StompAccelerationAdjust +StoreLandingData +StraightenMaxTurnInput +StraightenOutForce +StraightenOut_ForceTime +StraightenOut_Scalar +StrongWipeoutArms +StrongWipeoutLegs +SubjectTrackerMaxAcceleration +SubjectTrackerMaxSpeed +Surface +SurfaceSlope +SwanDive +SwingDisplacementLimit +SwingRagdollScalar +Switching +TRAJECTORY +TURN +Tail +TailBrakeForce +TailGrab +TailManual +TailTrick.360Flip +TailTrick.360Flip.LeftGround +TailTrick.360Flip.Takeoff.FromAntic +Takeoff +TargetDisplacementVel +TeleportAutoResetFadeOutTime +TeleportMaxTime +TeleportMinTimeAfterSettlingForAutoReset +TeleportMinTimeForAutoReset +TestDepth +TestDepthEpsilon +Thickness +ThrownSkateboardLaunchPitch +ThrownSkateboardPitchScalar +ThrownSkateboardPitchTarget +ThrownSkateboardRollScalar +ThrownSkateboardVelocity +ThrownSkateboardYawScalar +TiltVsRotAir +TiltVsRotGround +TiltVsSlopeAir +TiltVsSlopeGround +TiltVsTimeLanding +TiltVsTimeTakeOff +Tilt_TiltScalar +TimeBeforeAutoLineup +TimeMax +TimeSinceLastInput +TimeSinceLastPlayerInput +TimeToLand +TimeToRemoveDrives +TimeToRemoveHook +TipOffsetFraction +ToggleBoard +ToggleOffBoardState +Torpedo +TorqueBleedOffWithNoContact +TorqueDistScalar +TorqueScalarWithNoContact +TorqueVelScalar +TotalTestTime +TrajBlendAmountVsTime +TrajErrorBlendAwayTime +Trajectory +TrajectoryDispVsGroundNorm +TrajectoryDispVsSpeed +TrajectoryDisplacement +TrajectoryMaxDrop +TrajectoryMaxErrorEnd +TrajectoryMaxErrorStart +TrajectoryMaxTime +TrajectoryRadius +TrajectorySphereRadius +TrajectoryStartIndex +TransferAngle +TransitionTime +TransitionTimeUnits +TransitioningOnOffBoard +TranslationMultiplier +Trick +TrickFromManual +TrickHeight +Truck +TruckDisplacementMax +TruckDynamicFriction +TruckEnableVolumeCollisions +TruckMass +TruckRestitution +TruckRotationAxisAngle +TruckStaticFriction +TruckTightnessSpeedMaxAdjust +TruckTiltMaxAngle +TruckTiltScalar +TruckTiltWobbleScalar +TruckToWheel +TruckYPos +TruckZPosBack +TruckZPosFront +Truck_Back +Truck_Front +TrucksOrDeckInContact +Turn +TurnDeltaVsSpeed +TurnScale +TurnTorqueScalar +TurnTorqueVsNegAngVel +TurnTorqueVsSlope +TurnTorqueVsSpeed +TurnVsSpeed +TurnVsStickAngle +TurningCentredDeadzone +TurningCentredTime +TurningTime +TweakProject +TweakX +TweakY +Twist +TwistDisplacementLimit +TwistRagdollScalar +U_Fingerflip +U_Heelflip +U_Kickflip +U_L_B_Heelflip +U_L_B_Kickflip +U_L_F_Heelflip +U_L_F_Kickflip +U_N_Heelflip +U_N_Kickflip +U_Nollie +Underflip +UnderflipBoardContact +UnintentionalPumpScalar +Unlimited +Unrelated +Unsupported +UpVectAntiWobbleDamping +UpVectorMaxAcceleration +UpVectorMaxDeltaVsSpeed +UpVectorSmoothingFast +UpVectorSmoothingSlow +UpVectorSmoothingVsSpeed +UpdateIsWeightOnNose +UpdateManualOutTimer +UpdateRidingFakie +UpdateTimeSinceKickturn +UpdateTimeSinceTeleport +UseHardDrives +UseTruckDrives +Use_Drives +VarialHeelflip +VarialKickflip +Vel_E +VertJumpAlignFactor +VertJumpAlignMaxAngle +VertJumpAlignMaxGroundNormalY +VertJumpAlignMinJumpDirY +VerticalResponse +WCFG +WMET +WRONG +Walk +WallRideAngleToAllowLanding +WallRideAntiGravityVsTime +WallRideAutoJumpForce +WallRideAutoJumpHeight +WallRideAutoJumpYDownScalar +WallRideBoost +WallRideFootForceTime +WallRideMaxDotFloorWall +WallRideMaxTime +WallRideMinHeightFromGround +WallRideTestDist +WallRideVelTimeToConsider +WantsLeftAirGrab +WantsRightAirGrab +Wheel +WheelDynamicFriction +WheelMass +WheelRadius +WheelRestitution +WheelStaticFriction +WheelXDist +WillExpire +WindowFrameLength +WipeOutPushOff +WipeOutRecover +WipeOutRequest +Wipeout +WipeoutAirLightDMOScalar +WipeoutCheckForBadLanding +WipeoutControlX +WipeoutControlY +WipeoutFeetOffset +WipeoutGestureStickX +WipeoutGestureStickY +WipeoutGestureX +WipeoutGestureY +WipeoutGroundLightDMOScalar +WipeoutOverMinTime +WipeoutOverSpeed +WipeoutPushOff +WipeoutTimeSinceContact +WipeoutTimeToLand +Wipeout_AirBodyFlipScalar +Wipeout_AirFallingMaxAngle +Wipeout_AirFallingMinUpY +Wipeout_AirMaxSpeedIntoCollisionNearGrind +Wipeout_AirMaxSpeedIntoGround +Wipeout_AirMaxSpeedIntoStairs +Wipeout_AirMaxSquash +Wipeout_AirSkeletonMaxContact +Wipeout_AirSkeletonMaxContactArms +Wipeout_AirSkeletonMaxDisp +Wipeout_AirXZAcceleration +Wipeout_AirXZTrick +Wipeout_AirYAcceleration +Wipeout_AirYTrick +Wipeout_GroundBalanceBase +Wipeout_GroundBalanceMinSpeed +Wipeout_GroundBalanceTotal +Wipeout_GroundLeanContactYThresh +Wipeout_GroundMaxAngularDeckError +Wipeout_GroundMaxSquash +Wipeout_GroundMaxSquashCoffin +Wipeout_GroundOpposingContact +Wipeout_GroundSkeletonMaxContact +Wipeout_GroundSkeletonMaxContactArms +Wipeout_GroundSkeletonMaxDisp +Wipeout_GroundSkitchingContact +Wipeout_GroundSkitchingScalar +Wipeout_GroundSkitchingScalarArms +Wipeout_GroundVehicleContact +Wipeout_GroundVehicleScalar +Wipeout_GroundXZAcceleration +Wipeout_GroundYAcceleration +Wipeout_OB_Air_MinSpeed +Wipeout_OB_Air_SkelMaxContact +Wipeout_OB_Air_SkelMaxDisp +Wipeout_OB_MaxSquash +Wipeout_OB_MinSpeed +Wipeout_OB_SkeletonMaxContact +Wipeout_OB_SkeletonMaxContactArms +Wipeout_OB_SkeletonMaxDisp +Wipeout_OB_VehicleContact +Wipeout_OB_VehicleScalar +WorldGravity +WorldHeading +WrongSourceOperation +X +Y +__unknown__ +a0_zZz +abs +absorption_factor +absorption_ground_maxforce +absorption_maxforce +absorption_skateboard_damping +absorption_upforce +acceleration +action_graph +active +actor44 +actor56 +actor_input +adjustForVel +air +air-collision +alongSkateZ +always +and +angle +angleFilter +angleFlip +angleName +angleRot90 +angleRotN90 +angular +angular-drive +anim +anim_carving +anim_motion +anim_pump +anim_skitching +anim_wipeout +animation +animation_drives +animation_graph +animation_phase_packet.rs +animboardbackward +applyPosture +assets +attName +attr +attribute +augment +auto_pump +auto_pump_crouch_speed +auto_pump_crouch_time +auto_pump_intent_angle_region +auto_pump_intent_mag_start +auto_pump_max_crouch +auto_pump_potential_blend +auto_pump_potential_thresh +auto_pump_pump_speed +auto_pump_rise_speed +auto_pump_standing_thresh +auto_pump_sufficient_crouch +axis +balance +begin +behavior +behaviour +bind +bl_high_chase +blend +blendFalling +blendMatchFrame +blendMatchPhase +blendOut +blendRising +blendWithCurrentFrame +blend_acc_over_frames +blend_gesture_x +blend_gesture_y +blend_lean +blend_lean_in +blend_speed_over_frames +blend_twist +blendtime +board-error +board-height +board-velocity +board.angular_only +board.forward +board.height +board.set_velocity +board.steering_zero +board.velocity +board_query +board_state +board_toolkit +board_world_contacts +body_flip +body_spin +body_tilt +body_tilt_air_clamp_acc +body_tilt_air_clamp_vel +body_tilt_bodyspin_factor +body_tilt_ground_clamp_acc +body_tilt_ground_clamp_vel +bodytweakx +bodytweaky +bone +bone23 +bone_names +both +bound +box_box_sat.rs +brake_contact +bs +bumps +button_time_max +button_time_to_dv +cadence/tests.rs +camera +camera_compass +camera_droppredictor +camera_lag_profile +camera_positioner +camera_shake +camera_shots +camera_tracker +camera_tricks.rs +cannon_ball +ceiling +centre_of_mass +channelblend +character_scene +clamp +clampAcc +clampVel +clamp_curr_lean +clamp_gesture_to_antic +clamp_high_inclusive +clamp_jump +clamp_lean_vel +clamp_low_inclusive +clamp_twist_acc +clamp_twist_vel +class +classify +clear +clips +collections +collision-enabled:true +collision-flag +collision-force +collision-state:6 +collision-state:7 +collision.enabled:true +collision.state:7 +collision.update +comparison +compression-1 +condition +construction/authored_transforms.rs +construction/board_parts.rs +construction/deck_geometry.rs +construction/mass_moments.rs +construction/primitive_mass.rs +construction/principal_axes.rs +contact-clear +contact-identify +contact-initial +contact-publish +contact-stop +contact_correction/tests.rs +contact_data.rs +contact_reports.rs +controlled_drives_thresh +controller_startup.rs +coord +crouchName +crouch_blend_input +crouch_max_delta +crouch_max_delta_delta +crouch_max_ratio +crouching +crouching_max_height +crouching_min_height +damped_centre_of_mass +data +db +decel +default +defaultValue +defaultcyc +different +difficulty +dir +dirName +disable_push_brake_at_slope +distBoardToCogAnimAttribute +distance +disttocog +down +driveDistToCom +duplicate +dynamic_out_factor_speed_vs_acc +dynamics +easy +edges +either +elapsed-flag +elevation +enabled +end +end:4:40 +end:7:70 +endian +enqueue-force +equal +expression +extend +extend_bodyflip_gesture +extend_takeoff_point +face_face.rs +face_specialized.rs +fakie +fakieFilter +false +fields +filter +filter1 +filter2 +filteredIntent +first +fixtures/retail-collision.rwcmset +footplant-240:false +footplant-reset +footplant.flag:false +footplant.lock +footplant.pose +footplant.prediction +footplant.reset +footplant.shift +force +four +fps +fps_at_scale_one +frame_ms_mean +frame_ms_median +frame_ms_p95 +frames +frames_tests.rs +free +free_fall +freecam +from +fs +gesture +glb +global-byte +global_anticipation_delay +global_tolerance_dist +global_tolerance_speed +global_tolerance_time +goingdown_disttocog_speed +goingup_disttocog_speed +grab +grabName +graph.xml +greater +greaterEqual +greaterThanAbs +grind +grind.disable: +grind.start +grind.started:false +grindName +grind_air_settings.rs +grind_camera.rs +grind_chromosome.rs +grind_disttocog_speed +grind_height +grind_names.rs +grind_twist +ground +ground-flag +ground_position +ground_up +group +halfpipe_left_coping +halfpipe_right_coping +hand +hardcore +head +head.target:true +heading +height +high +highSpeedThreshold +high_flat_rail +hips +history_tests.rs +holding +holdingName +hook +hook_step.rs +identify +ik:true +inair_disttocog +inclination +include +indices +initial +initial_animation +input.txt +input_and_state +input_teleport.rs +inputlistener +intent +intent-test +intentX +intentY +intent_filter/tests.rs +interruptable +invalid +invertY +isRightFoot +joint_data.rs +judo_kick +jump +jumping +key +kickturn +kickturn_balance +kickturn_spin +known-air +landingType +landingondeck_disttocog +lastAnim +last_push_out_time +launch +launch-construct +launch-fill +left +left_foot +left_hand_grab +left_stick +length +less +lessEqual +linear +lo_air_arm_extend +local +locostate +lowSpeedThreshold +low_flat_rail +low_ollie +lowercase +made-up +magnitude +main_schedule_ms_mean +manager +mantle +maps +mask +math.restore_geometry +math.signed_angle +math.wrap +maxHeight +max_bump_mag +max_grind_disttocog +max_holding_acc +max_phys_pump +med +mesh_bind_groups +min_bump_blend_value +min_bump_mag +min_grind_disttocog +min_inair_disttocog +mirrorAnim +mirrorAugment +mirrorFilter +mirrored +missing +motion +motion_air_execute.rs +motion_execute.rs +motion_filter_execute.rs +motion_graph +motion_hand_services/tests.rs +motion_offboard/air.rs +motion_offboard/cadence.rs +motion_offboard/push_off.rs +motion_offboard/reset.rs +motion_offboard/twist_lean.rs +motion_sliding_execute.rs +motion_stock_execute.rs +motion_wipeout_execute.rs +motorized +name +nameToFind +names +negate +negateOnMirror +newPushName +new_pump_thresh +noBoardAnim +none +normal +normalize +not +notEqual +not_sliding_threshold +not_sliding_time_thresh +numlandings +object +offboard +offboard_intentions/tests.rs +offboard_preland_disttocom_vel_multiplier +offboardair +onFirstUpdateOnly +onUpdate +onback +one +oneMinus +onfront +op +or +orientation +outputs_and_camera +overide_preland_velY +overide_preland_x +override +pan +param +param:Drive:first:3F800000:7 +param:Drive:second:40000000:9 +parent +path +pattern +physics +physics_airstates +physics_animation +physics_biped +physics_bodyspin +physics_brakes +physics_collision +physics_deck_wobble +physics_feet +physics_footplantmanager +physics_friction +physics_grinds +physics_grinds_air +physics_hat +physics_heading +physics_jump +physics_manual +physics_mode +physics_ms_per_frame +physics_ms_per_tick +physics_pumping +physics_push +physics_reckoning +physics_skatecontroller +physics_skeleton +physics_skeleton_bones +physics_skeleton_drives +physics_skeleton_joints +physics_skeletonik +physics_slide +physics_speed_conservation +physics_speed_wobble +physics_state_offboard +physics_state_offboard_air +physics_state_skitching +physics_steering +physics_surfaces +physics_ticks_per_frame +physics_trajectory +physics_wipeout +physics_wipeout_control +physics_world +physicsdeck +physicstrucks +physicstrucks_drives +physicswheels +pitch +platform_back_edge +platform_left_edge +platform_right_edge +play +playBackSpeed +png +pose +position_output/tests.rs +postcond +posture/tree_tests.rs +power_slide +precond +predict +preland_disttocom_vel +preland_final_disttocom +prepare +primitive_geometry.rs +priority +private +private/custom/climbing.json +private/custom/crouch-treflip.json +private/game.json +private/skater.glb +private/skater.json +private/stock +private/stock/data/camera +private/stock/data/config/input.cfg +private/stock/data/joystick +private/stock/data/script/camera/Default_cameragraph.stategraph +private/stock/data/state/ActionGraph_OnBoard.stategraph +private/stock/data/state/MotionGraph_OnBoard.stategraph +private/stock/physics-skeletons.json +private/stock/skater-collections.json +probe +probe_reset +publish +pump +pump_amplify +pump_blendin +pump_blendout +pump_maxspeed +pump_maxspeed_yaxis +pump_prop_blend +pumping-frame +pumping-height +push +push_contact +pushing +pushing_usemaxpushfromteleporttime +quickAngleMap +quickAngleOffset +quickMagMap +quickness +quicknessName +ram:/missing +ram:/world/objects/car +rampTime +reach +reckoning +reckoning.begin_flip:true +reckoning.reset_flip +reckoning.update_air +reckoning_up +recognizer +reference +regularAttributes +release:40 +release:70 +rename +render_assets_ms_mean +render_prepare_ms_mean +render_queue_ms_mean +render_submit_ms_mean +render_views_ms_mean +replacement +reset +riding_intentions_tests.rs +right +right_foot +right_hand_grab +right_stick +righthand +root +root_drive_controlled_scalar +root_drive_end_scalar +root_drive_scalar +root_drive_start_scalar +rotation +rough +samples +scale +scale_x_acc +scene +second +sections_ms_per_call +selector-update +selector.closest +selector.contact +selector.reset +sequence +sequenceid +set +set-board-velocity +setManually +setToValue +settings-base +settings/default-map.json +settings/gameplay.json +settings/graphics.json +sha256 +signup +skate +skate3rust.rc +skate3rust.res +skate_game::riding_trace +skateboard +skater +skater-collections.json +skater.pat +skater90.pat +skaterN90.pat +skater_air.pat +skater_fingerflip.pat +skaterls.pat +skaterstep.pat +skeleton +skeleton.com +skeleton.ik:true +skeleton.truck_tilt:false +skeleton.update_known_air +skeleton_body_collision_settings.rs +skeleton_body_drive_settings.rs +skeleton_input_general.rs +skeleton_input_teleport.rs +skeleton_root +skeleton_world_contacts +sketchy +slide +slide/tests.rs +slide_input_remap +slide_min_entry +slide_phys_to_anim_spin +slide_smooth +slide_speed_factor +slide_speed_threshold +slide_speed_to_lean +slide_turn_value +slidespin +slippery +slopetype +slow +slowAngleMap +slowAngleOffset +slowMagMap +slowmotion_controller +smith +smooth +solve_total +source +source_bank +source_bytes +source_sha256 +special +speed +speedName +speed_tuck +speed_tuck_start +speedtuck +spin +spinScale +spin_clamp_influence_delta +spin_clamp_influence_deltadelta +spin_influence_blendin +spin_influence_blendout +spin_map +startingValue +state +stategraph +steering-tilt:0 +stop +straight +subject_tracker +submit +sustain +swan_dive +switch +switchAnim +system +tUrN +tailgrab_airwalk +target +teleport +test +test-only +tests/action_host.rs +tests/adjust.rs +tests/air_playback.rs +tests/animation_pose.rs +tests/assembly.rs +tests/bits.rs +tests/board_ground.rs +tests/board_runtime.rs +tests/board_step.rs +tests/board_world.rs +tests/clock.rs +tests/collision_feedback.rs +tests/conditions.rs +tests/contact.rs +tests/contact_solver.rs +tests/controllers.rs +tests/deck_geometry.rs +tests/difficulty.rs +tests/drive_frames.rs +tests/drive_solver.rs +tests/filtered_state.rs +tests/frame.rs +tests/graph_runtime.rs +tests/ground_orientation.rs +tests/input_config.rs +tests/intent_handlers.rs +tests/intents.rs +tests/joint_builder.rs +tests/joint_records.rs +tests/joint_solver.rs +tests/kickturn.rs +tests/lifecycle.rs +tests/logic.rs +tests/mass_moments.rs +tests/motion_bump.rs +tests/motion_slide.rs +tests/motion_stock.rs +tests/motion_tricks.rs +tests/native_arithmetic.rs +tests/physics_startup.rs +tests/pose_blend.rs +tests/pose_sample_vbr.rs +tests/post_input.rs +tests/post_output.rs +tests/post_state.rs +tests/powerslide_playback.rs +tests/pre_state.rs +tests/primitive_query.rs +tests/push.rs +tests/push_behaviors.rs +tests/pushing.rs +tests/pushing_metadata.rs +tests/rigid_body.rs +tests/runtime.rs +tests/skeleton_animation_record.rs +tests/skeleton_board_offset.rs +tests/skeleton_landing.rs +tests/speed_and_slope.rs +tests/state_phase.rs +tests/steering.rs +tests/triangle_query.rs +tests/trick_turn.rs +tests/truck_frames.rs +tests/wipeout_playback.rs +tests/world_dispatch.rs +text +three +tilt +tilt_x +time +timeFromTeleportThreshold +timeSlowlyRollingBackwardsThreshold +timescale +tolerance_dist +tolerance_time +toolkit +torpedo +torso +trailing +trajectory +trajectory.highest +transType +transition +transitionIn +transitionOut +transitionUnder +triangle_box_sat.rs +trick +trickFromGrind +trickFromManual +true +truncated +turn +tweak +tweak_x +tweak_y +twist +twistAnimAttribute +twistMGIntent +twist_lean/tests.rs +twist_max_delta +twist_max_delta_delta +twist_sensitivity +twist_smoothing +two +type +unavailable +unbounded +unknown +unknown-event +unknown-name +unsupported_trees +up +update +urgent +useChannelFromWeights +use_gesture_speed +value +version +veryslow +volume +waitForTransitions +well_into_slide_time +wheel_and_foot_queries +width +windows +wipeout +wipeout.check:true +wipeout:false +world_grab_y +world_grab_z +x64/rc.exe +xinput +yaw +zero diff --git a/tools/asset_pipeline/physics_skeleton.py b/tools/asset_pipeline/physics_skeleton.py new file mode 100644 index 00000000..e13512eb --- /dev/null +++ b/tools/asset_pipeline/physics_skeleton.py @@ -0,0 +1,44 @@ +"""Export the physics records already decoded by skate-data's ABIN reader.""" +from pathlib import Path +import hashlib,json,struct +from .vlt import take,unpack + +def fast_name(data,at,words): + alphabet='0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_' + result='' + for word in unpack('I'*words,data,at): + divisor=38**5 + for _ in range(6): + digit,word=divmod(word,divisor) + if not digit: + if word: raise ValueError('Invalid FastString padding') + break + if digit>len(alphabet): raise ValueError('Invalid FastString digit') + result+=alphabet[digit-1] + divisor//=38 + return result + +def convert(path): + data=path.read_bytes() + end,version=unpack('II',data,0) + if version!=1 or end>len(data):raise ValueError('Invalid animation bank') + skeletons=[];at=48 + while atend:raise ValueError('Invalid ABIN record') + if kind==5: + payload=(at+55)&~15 + count,=unpack('I',data,payload) + start=payload+16 + take(data,start,count*112) + bones=[] + for i in range(count): + pos=start+i*112 + # Bone words+23..27 contain FastString30 (the same record + # consumed by native skeleton construction). + bones.append({'name':fast_name(data,pos+92,5), 'source_offset':pos, + 'words':list(unpack('I'*28,data,pos))}) + skeletons.append({'name':fast_name(data,at+16,6),'source_offset':at,'bones':bones}) + at+=size + if not skeletons:raise ValueError('No physics skeletons in animation bank') + return {'version':1,'source_sha256':hashlib.sha256(data).hexdigest(),'skeletons':skeletons} diff --git a/tools/asset_pipeline/vlt.py b/tools/asset_pipeline/vlt.py new file mode 100644 index 00000000..930adaa9 --- /dev/null +++ b/tools/asset_pipeline/vlt.py @@ -0,0 +1,132 @@ +"""Read Skate 3's big-endian, 64-bit AttribSys schema and collections. + +Layout reference: NFSTools/VaultLib (MIT), ModernBase structures. Data comes +only from the user's BIN/VLT pair. Unknown names retain their numeric hash. +""" +from pathlib import Path +import hashlib +import json +import re +import struct + +MASK = (1 << 64) - 1 + +def mix(a, b, c): + for who, shift in [(0,43),(1,-9),(2,8),(0,38),(1,-23),(2,5), + (0,35),(1,-49),(2,11),(0,12),(1,-18),(2,22)]: + if who == 0: a = ((a-b-c) ^ (c >> shift)) & MASK + elif who == 1: b = ((b-c-a) ^ (a << -shift)) & MASK + else: c = ((c-a-b) ^ (b >> shift)) & MASK + return a,b,c + +def hash64(text): + if not text: return 0 + data = text.encode('ascii') + a = b = 0xABCDEF0011223344 + c = 0x9E3779B97F4A7C13 + pos = 0 + while len(data)-pos >= 24: + x,y,z=struct.unpack_from('len(data): + raise ValueError(f'Truncated AttribSys record at {at:#x}, size {size}') + return data[at:at+size] + +def unpack(fmt,data,at): + return struct.unpack('>'+fmt,take(data,at,struct.calcsize('>'+fmt))) + +def chunks(data): + at=0 + while atI',target,offset,0 if kind==1 else dest) + elif kind!=4: raise ValueError(f'Unknown VLT pointer kind {kind}') + elif tag==0x4578704e: + count,=unpack('Q',v,at) + if count>(size-8)//24: raise ValueError('Invalid VLT export count') + exports=[unpack('QQII',v,at+8+i*24) for i in range(count)] + return v,b,exports + +def convert(schema_stem, collections_stem, names): + sv,sb,se=vault(schema_stem) + cv,cb,ce=vault(collections_stem) + strings=set(names) + for data in (sb,cb): + strings.update(s.decode('ascii') for s in re.findall(rb'[A-Za-z_][A-Za-z0-9_:./ -]{1,180}\x00',bytes(data)) for s in [s[:-1]]) + lookup={hash64(s):s for s in strings} + def name(key): return lookup.get(key,f'Hash_{key:016X}') if key else '' + classes={} + for _,kind,size,at in se: + if kind!=0x2A7895AC4A876152: continue + key,reserve,count,defs,static_size,static,layout=unpack('QIIIIII',sv,at) + fields={} + for i in range(count): + fkey,typ,offset,n,maximum,flags,alignment=unpack('QQHHHBB',sb,defs+i*24) + fields[fkey]=(typ,offset,n,maximum,flags,alignment) + classes[key]=(fields,static) + rows=[] + source_hash=hashlib.sha256(collections_stem.with_suffix('.vlt').read_bytes()).hexdigest() + for _,kind,size,at in ce: + if kind!=0xAD303B8F42B3307E: continue + key,cls,parent,reserve,pad,count,ntypes,typeslen,layout,pad2=unpack('QQQIIIHHII',cv,at) + fields,static=classes[cls] + out={} + def value(fkey,data,pos,inline=False): + typ,offset,n,maximum,flags,alignment=fields[fkey] + t=name(typ) + length=4 if inline else n + if t=='EA::Reflection::Text' and not flags&1: + ptr,=unpack('I',data,pos) + if ptr: + end=cb.find(b'\x00',ptr) + if end<0: raise ValueError(f'Unterminated VLT text at {pos:x}: ptr={ptr:x} field={name(fkey)} size={n}') + raw=take(cb,ptr,end-ptr).decode('utf-8') + else: raw='' + if raw.isascii(): lookup[hash64(raw)]=raw + out[name(fkey)]={'type':t,'data':raw} + else: + raw=take(data,pos,length) + out[name(fkey)]={'type':t,'data':raw.hex().upper()} + for fk,f in fields.items(): + if f[4]&2 and layout: value(fk,cb,layout+f[1]) + entries=at+48+typeslen*8 + for i in range(count): + pos=entries+i*16 + fk,ptr,ti,node_flags,entry_flags=unpack('QI HBB',cv,pos) + if fk not in fields: raise ValueError(f'Unknown schema field {fk:x}') + f=fields[fk] + if f[2]<=4 and not f[4]&1: value(fk,cv,pos+8,True) + else: value(fk,cb,ptr) + rows.append({'class':name(cls),'key':key,'parent':parent,'fields':out, + 'source':'skatercollections.vlt','sha256':source_hash}) + for row in rows: + row['key']=name(row['key']) + row['parent']=name(row['parent']) + return {'version':1,'collections':rows} diff --git a/tools/default_skater_retail_manifest.json b/tools/default_skater_retail_manifest.json new file mode 100644 index 00000000..6da912f3 --- /dev/null +++ b/tools/default_skater_retail_manifest.json @@ -0,0 +1,328 @@ +{ + "schema": 1, + "platform": "xbox360", + "title_update": "TU3 / retail 1.05", + "preset": { + "name": "SavedRecipeFallbackMale", + "recipe_sha256": "FDE81E75125250266EE3118705ACB921B6A89F903810683BF91190FC1C13237E", + "recipe_size": 157948, + "payload_offset": 16, + "recipe_name": "cas_db", + "recipe_type": 3, + "gender": 1, + "alternative_female_recipe_sha256": "D8F0227F5B93C688CE7093A6694BFFB2455C8FF643D7F6FE61F6A2317281C8F5", + "body_mods": { + "skinniness": 0.0, + "fatness": 0.0, + "face_fields": 0.25 + } + }, + "morph_assembly": { + "encoding": "rx2-dense-position-delta-direct-weight-v1", + "face_targets": [ + "local_brows_depth", + "local_brows_height", + "local_brows_rotation", + "local_chin_length", + "local_eye_height", + "local_eye_rotation", + "local_eye_width", + "local_jaw_chiseled", + "local_jaw_depth", + "local_mouth_corner", + "local_mouth_height", + "local_mouth_lipsize", + "local_mouth_width", + "local_nose_curve", + "local_nose_height", + "local_nose_length", + "local_nose_width" + ], + "expected_targets": { + "Arm": [ + "fat_male_arms_shoulderdown_h", + "thin_male_arms_shoulderdown_h" + ], + "Feet": [], + "Hair": [], + "Organ": [], + "OuterTorso": [ + "fat_tanktop_h", + "thin_tanktop_h" + ], + "Pants": [ + "fat_denim_straight_leg_pants_male_h", + "thin_denim_straight_leg_pants_male_h" + ], + "Rostral": [ + "fat", + "local_brows_depth", + "local_brows_height", + "local_brows_rotation", + "local_chin_length", + "local_eye_height", + "local_eye_rotation", + "local_eye_width", + "local_jaw_chiseled", + "local_jaw_depth", + "local_mouth_corner", + "local_mouth_height", + "local_mouth_lipsize", + "local_mouth_width", + "local_nose_curve", + "local_nose_height", + "local_nose_length", + "local_nose_width", + "thin" + ], + "SkateBoard": [], + "SkateTruck": [], + "SkateWheel": [] + } + }, + "archive": { + "name": "createacharacter.big", + "sha256": "B87E9E01D446DF37D707D0F2AC2AB872BAF29BBA91EEAE5FD08997C7D475D4EB", + "size": 472328128 + }, + "catalog": { + "composite": "cas_db_xfd1ca62f_000", + "outer_torso_requires_arm_model": "shoulder", + "outer_torso_type": "tanktop", + "pants_wear_style": "low waist pant", + "selected_lod": 0, + "unselected_slots": [ + "Accessory", + "Glasses", + "Hat", + "InnerTorso", + "Jewellery", + "Leg", + "Misc", + "Sock", + "WristItem" + ] + }, + "components": [ + { + "slot": "Arm", + "asset_id": "af71f8ae8ef28d10", + "model_id": "00000dce03e38811", + "model_arena_id": "b00d7ceaa2cb1b8b", + "low_model_id": "00000dcf03e38811", + "low_model_arena_id": "d1b961a8181c66a0", + "material_id": "0000557b03e38817", + "tint": [0.72, 0.57, 0.49], + "alpha_mode": "OPAQUE", + "textures": { + "blurmask": "0000228f03e38818", + "specular": "0000216603e38818", + "normal": "0000229103e38818", + "decal": "00000e7403e38818", + "diffuse": "0000228c03e38818", + "decal2": "2c7f3818000201de" + } + }, + { + "slot": "Feet", + "asset_id": "85b73da337acc9ce", + "model_id": "2c7f381100170192", + "model_arena_id": "8435d230d628fc21", + "low_model_id": "2c7f381100170193", + "low_model_arena_id": "8435d230d628fc20", + "material_id": "2c7f381700171aa8", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "2c7f381800171957", + "diffuse": "2c7f381800171958", + "decal2": "2c7f3818001702f9" + } + }, + { + "slot": "Hair", + "asset_id": "bab72a34fa64163b", + "model_id": "00000de203e38811", + "model_arena_id": "cf9fd166506ed9b7", + "low_model_id": "00000de303e38811", + "low_model_arena_id": "9b4d6ba733dcee6e", + "material_id": "00000c4c03e38817", + "tint": [0.02, 0.01, 0.01], + "alpha_mode": "MASK", + "alpha_cutoff": 0.5, + "textures": { + "alpha": "00000b8e03e38818", + "normal": "00000b8d03e38818", + "diffuse": "00000b8c03e38818" + } + }, + { + "slot": "Organ", + "asset_id": "b278a4bfb3d6c4bc", + "model_id": "00000e1a03e38811", + "model_arena_id": "b248ee9743b353d4", + "low_model_id": "00000e1b03e38811", + "low_model_arena_id": "967dd98bf69f281f", + "material_id": "00000c6203e38817", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "00000bb903e38818", + "environment": "000000ae03e38818", + "diffuse": "00000bb803e38818" + } + }, + { + "slot": "OuterTorso", + "asset_id": "a3a732c5dd7e7e2c", + "model_id": "00000ec603e38811", + "model_arena_id": "e9317c9a61e316dd", + "low_model_id": "00000ec703e38811", + "low_model_arena_id": "e44b4f1f38d703ec", + "material_id": "0000129b03e38817", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "00000fa103e38818", + "diffuse": "00000fa203e38818", + "decal2": "00000bc103e38818" + } + }, + { + "slot": "Pants", + "asset_id": "d92b14a037b78985", + "model_id": "2c7f381100170177", + "model_arena_id": "86947c3d00e4326b", + "low_model_id": "2c7f381100170178", + "low_model_arena_id": "86947c3d00e43264", + "material_id": "2c7f3817001719cd", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "2c7f3818001718aa", + "diffuse": "2c7f3818001718a9", + "decal2": "2c7f3818001702f9" + } + }, + { + "slot": "Rostral", + "asset_id": "9461df2eb3d482dc", + "model_id": "00000ee803e38811", + "model_arena_id": "99fd3f0b428a0ed1", + "low_model_id": "00000ee903e38811", + "low_model_arena_id": "d009e2ecab1bca60", + "material_id": "2c7f3817001715dc", + "tint": [0.72, 0.57, 0.49], + "alpha_mode": "OPAQUE", + "textures": { + "blurmask": "00001e8403e38818", + "specular": "2c7f381800171ebf", + "normal": "2c7f381800171ec3", + "decal": "2c7f3818001702f8", + "diffuse": "2c7f3818001714ee" + } + }, + { + "slot": "SkateBoard", + "asset_id": "a841a65d5c4dd593", + "model_id": "0000157803e38811", + "model_arena_id": "b593dc0feab56475", + "low_model_id": "0000157903e38811", + "low_model_arena_id": "98009e1d35783be4", + "material_id": "000044e603e38817", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "0000128503e38818", + "decal": "00000f9603e38818", + "diffuse": "0000136403e38818", + "decal2": "0000135c03e38818" + } + }, + { + "slot": "SkateTruck", + "asset_id": "c6f01c93097b7fb5", + "model_id": "0000157a03e38811", + "model_arena_id": "dfecb509271926a2", + "low_model_id": "0000157b03e38811", + "low_model_arena_id": "88391098cbc316f1", + "material_id": "000048e603e38817", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "0000137203e38818", + "diffuse": "000013a703e38818" + } + }, + { + "slot": "SkateWheel", + "asset_id": "87d0fa0ea0366b23", + "model_id": "0000157c03e38811", + "model_arena_id": "c114bcf0f996c74e", + "low_model_id": "0000157d03e38811", + "low_model_arena_id": "ea8500ad6cdc5ed9", + "material_id": "00004acc03e38817", + "tint": [1.0, 1.0, 1.0], + "alpha_mode": "OPAQUE", + "textures": { + "normal": "0000128303e38818", + "diffuse": "000015b703e38818" + } + } + ], + "model_sha256": { + "00000dce03e38811": "F17AAA8A44A78FF5844950C953E15AE817458F11EE34856874D1EB9EBFC50FD3", + "2c7f381100170192": "1321D50D9984C2A771EF0CC48FFB9BE9A63DAEAED0308D4909CC1A4BBF8AC196", + "00000de203e38811": "B8A440AD58EAE3B5F375354892B18E4EE518B14CF841AE8862B8E9D0686E3E61", + "00000e1a03e38811": "E56AE27BE3BA883F2DE73695192E80955DAE9DAD2E552CC170D7254F1D19550B", + "00000ec603e38811": "6DF415A645383F7366BA08CCB473058B5DBD6C9B9186761124816D44FE210AB9", + "2c7f381100170177": "5B47EC40843B34CEB253EA6E8FCD48B4AF8C0B526176D6131EC7E7F5AD4D5837", + "00000ee803e38811": "C8D5BA552DE1405812BAA1124F8E97E7B8A79385DBA15CEF559100AB3672CB5B", + "0000157803e38811": "83EF4A2DAAFF090DDA2DFC6E23876D5DB05AEA8389C1C8BE9950E60141E6F623", + "0000157a03e38811": "25BEE11C762E727ACE291424423EE9D3F5059D546822D8E1868FD38542874AAA", + "0000157c03e38811": "6B1410C35219D7C70389F1596AB5849E3D4B51A8981B358EB11F9229BD1B65E5" + }, + "texture_sha256": { + "0000228f03e38818": "7A2FB8AB8FF4A2CE6C6F7B0E4B3DC126F48A4AC75ADD22DAB389A07DCDFF0ED3", + "0000216603e38818": "23A592E8560194F8939E9C6EFD4B1F64881E1B91CA894F163071F79ADC22053D", + "0000229103e38818": "43C1F235E50D1D49A46439FC853BC64A948DF609F14C8F19EF7B1A3A285D9E65", + "00000e7403e38818": "5F09CB277E1DB524B3F978C1D4E76D4920C6C9CF55C04DE88C1167ECBF2CAA5E", + "0000228c03e38818": "FFAF5C5CB95B035496E4A86EDF8F91A0314FD63EBF625BB37B211E0F36A4CE3E", + "2c7f3818000201de": "42BBAC8091AB6FC95D99F325E59A4B5A495CC96209599C7A45A40BD61EC0A213", + "2c7f381800171957": "C5F22146986A49E03FBEF414690BA6CF32FEAF05A7C79B83CEE3E9816A2C49CB", + "2c7f381800171958": "E351F7C0BFDCC91ED5628BDA4A20F9B92162CCDBF024CE271656D913486875A1", + "2c7f3818001702f9": "78AC84D4FC1A7DA287CC4A63433F33DF10C5B2C0CAF23428AFE1434329E75759", + "00000b8e03e38818": "6BDBC0C40BA7BD8A9CCCA12BCE0B25318FA3641C53774B9B639B80E6A5FF76CA", + "00000b8d03e38818": "4F4071BDB9298A22568BE1989EA514004617B910706674B36BA8616239022859", + "00000b8c03e38818": "EC07B105F8842F6A2A63671BD46CDCA86DB8E7B901592D6958720A9B36A238E9", + "00000bb903e38818": "3D4FC691AA70D0C8DD4D70106C9EDD53EB47CF74C3CD9E611712E855C4837C6E", + "000000ae03e38818": "F6F38E5BB43AC97F5C4E208E3083AE0074F87444B207A44F134F75140522E1E7", + "00000bb803e38818": "F0D650314F1E981E093583D557CF33E7A402D7642CD35833E8BFB63AA09398C2", + "00000fa103e38818": "E27819073F5DD7E7E1C245C0F249D3593D7036F41F5E07376A80598D3AA1BC11", + "00000fa203e38818": "4A9DA44B56BD0DCA89E011FBA7883D2C0243FC77B5B8C216605B9717A00285AE", + "00000bc103e38818": "F39F911381D41C0D0869FCB59C0D734E2FD089501B81EE7CEBC80EE98D3FB834", + "2c7f3818001718aa": "7EB3158D87604F79555B194EDB8877A2AF0B939F157E036F4C36F4EEA2F9234D", + "2c7f3818001718a9": "CA0BB1EF315091D85248BA2FDB98F0C114A3D38C1BC84E19D6F60282CF7DAF4E", + "00001e8403e38818": "E17457281CBDFCF77DEA241A1AEBFEE5A2773CEA349B62B3EB3104A6BEDAB105", + "2c7f381800171ebf": "82F82A90B84378DA741B7AA88E72CB4EF483D15E976754BCC8B3E3D83C37C29A", + "2c7f381800171ec3": "0FC45251F55ECA1F9E15FB33E290EFEF0CA27F324460003C794A7DF6E06F2691", + "2c7f3818001702f8": "516D9C45B1A4957321A2AEA3CBCEEB1AB44D3BA3BDD0BABD492B903F28236338", + "2c7f3818001714ee": "3C58F5070B8DD0ACAD5E1151168E8E7FA4414FADDC1C148D39BAD45382C6C2F6", + "0000128503e38818": "16DCB338A728877AEB6F5080F080AF83432B77825ECE2F0FA82AEB150B3BC4EF", + "00000f9603e38818": "07387C6EC3600D8A5D665553260F89263C502F89DFC814C50FE78A14609266DA", + "0000136403e38818": "00D8BCAE2F18204E05A23D681580E72A01814490E56A26BD9D37B6959F9DD7E4", + "0000135c03e38818": "36A17CEE8EB7A033C42E32A6A489FEC60B6EB1708971320176401B6C2E1C2AA7", + "0000137203e38818": "1A0565A75F33AF0E0CDC12863875619E5F78CDBA8A0B218436BDBDE1E50FD4EF", + "000013a703e38818": "4C0EC4D1C440FB2608C5B26D76C10D9D077E209373274294C93A24B19E255EB7", + "0000128303e38818": "2344E415184292C4FD5AD550DD19EFD9BDA046691E1A5F67AD7546263B19B88B", + "000015b703e38818": "A94F3754D662E01EEF887B4F11D9944444557F0DA5451DE4AFB97411EBF21AE7" + }, + "rig_evidence": { + "retail_cac_bone_count": 131, + "cac_skeleton_sha256": "29199C2013441880D1FDE75EDFC5381046A55CCA69B8BED442D80F8CD9ACF34A", + "selected_unique_rx2_bones_including_board": 110, + "selected_vertices": 9415, + "selected_triangles": 14584, + "runtime_policy": "Map shipped vertex influences by retail bone name into the existing compact OnBoard/OffBoard carrier; absent helper/finger channels resolve to the nearest retail ancestor while the full 131-channel bind evidence is validated offline." + } +} diff --git a/tools/export_bevy_glb.py b/tools/export_bevy_glb.py new file mode 100644 index 00000000..b260810d --- /dev/null +++ b/tools/export_bevy_glb.py @@ -0,0 +1,324 @@ +"""Prepare a private Skate 3 RX2/ABIN Blender scene for Bevy glTF import. + +This script is deliberately project-local and does not redistribute source +game data. It joins the eight compatible skinned RX2 parts, removes baked +horizontal travel in the board's frame from the complete rider/board pair, +and exports every selected retail action as a named glTF animation. +""" + +from pathlib import Path +import json +import sys + +import bpy +from mathutils import Vector + + +HIPS = "HIPS" +BOARD = "SKATEBOARD_ROOT" +TOE_TARGETS = ( + ("RIGHTTOEBASE", "RIGHTTOEBASE_REPARENTED"), + ("LEFTTOEBASE", "LEFTTOEBASE_REPARENTED"), +) + + +def parse_args() -> tuple[Path, Path, Path]: + if "--" not in sys.argv: + raise RuntimeError( + "Expected output GLB, manifest, and root-motion paths after --" + ) + tail = sys.argv[sys.argv.index("--") + 1 :] + if len(tail) != 3: + raise RuntimeError( + "Usage: blender source.blend --python export_bevy_glb.py " + "-- output.glb manifest.txt root_motion.json" + ) + return ( + Path(tail[0]).resolve(), + Path(tail[1]).resolve(), + Path(tail[2]).resolve(), + ) + + +def iter_fcurves(action): + """Yield f-curves from Blender 4+/5 layered actions.""" + for layer in action.layers: + for strip in layer.strips: + for channelbag in strip.channelbags: + yield from channelbag.fcurves + + +def location_curves(action, bone_name): + path = f'pose.bones["{bone_name}"].location' + return { + fcurve.array_index: fcurve + for fcurve in iter_fcurves(action) + if fcurve.data_path == path + } + + +def set_curve_value(fcurve, frame, value): + for point in fcurve.keyframe_points: + if abs(point.co.x - frame) <= 1.0e-4: + difference = value - point.co.y + point.co.y = value + point.handle_left.y += difference + point.handle_right.y += difference + return + fcurve.keyframe_points.insert(frame, value) + + +def bake_root_space_in_place(armature) -> tuple[int, int, float, float, dict]: + """Remove shared travel without erasing body-to-board registration. + + HIPS and SKATEBOARD_ROOT use different local bases, so independently + zeroing selected location channels is not a valid in-place conversion. + For every source frame, translate both evaluated roots by one shared + horizontal displacement, then write the resulting local-basis locations + back to their own curves. OnBoard actions anchor to SKATEBOARD_ROOT; + OffBoard BR/NB actions anchor to HIPS so the carried board retains its + authored relationship to the skater. + """ + + armature.animation_data_create() + tracks = list(armature.animation_data.nla_tracks) + track_mutes = [track.mute for track in tracks] + for track in tracks: + track.mute = True + + changed_curves = 0 + changed_keys = 0 + maximum_anchor_travel = 0.0 + maximum_target_vector_error = 0.0 + root_motion = {} + try: + for action in bpy.data.actions: + hips_curves = location_curves(action, HIPS) + board_curves = location_curves(action, BOARD) + if len(hips_curves) != 3 or len(board_curves) != 3: + continue + + armature.animation_data.action = action + start = int(action.frame_range[0]) + end = int(action.frame_range[1]) + offboard = action.name.startswith(("BR_", "NB_")) + anchor_name = HIPS if offboard else BOARD + samples = {} + source_anchor_samples = [] + source_target_vectors = {} + for frame in range(start, end + 1): + bpy.context.scene.frame_set(frame) + bpy.context.view_layer.update() + hips = armature.pose.bones[HIPS] + board = armature.pose.bones[BOARD] + anchor = armature.pose.bones[anchor_name] + source_anchor_samples.append( + { + "frame": frame, + "position_blender": [ + float(anchor.matrix.translation.x), + float(anchor.matrix.translation.y), + float(anchor.matrix.translation.z), + ], + } + ) + + source_target_vectors[frame] = tuple( + ( + armature.pose.bones[toe_name].matrix.translation + - armature.pose.bones[target_name].matrix.translation + ).copy() + for toe_name, target_name in TOE_TARGETS + ) + + horizontal = Vector( + (anchor.matrix.translation.x, anchor.matrix.translation.y, 0.0) + ) + desired_board = board.matrix.copy() + desired_board.translation -= horizontal + desired_hips = hips.matrix.copy() + desired_hips.translation -= horizontal + + # Assignment through pose matrices lets Blender perform each + # root's distinct parent/rest-basis conversion. + board.matrix = desired_board + hips.matrix = desired_hips + bpy.context.view_layer.update() + samples[frame] = ( + tuple(float(value) for value in hips.location), + tuple(float(value) for value in board.location), + ) + + origin = source_anchor_samples[0]["position_blender"] + for sample in source_anchor_samples: + position = sample["position_blender"] + # Blender's source rig is Z-up. glTF/Bevy is Y-up and the + # exporter maps source -Y to Bevy +Z. + sample["delta_bevy"] = [ + position[0] - origin[0], + position[2] - origin[2], + -(position[1] - origin[1]), + ] + del sample["position_blender"] + root_motion[action.name] = { + "anchor": anchor_name, + "frame_start": start, + "frame_end": end, + "sample_rate_hz": 60.0, + "samples": source_anchor_samples, + } + + for frame, (hips_location, board_location) in samples.items(): + for axis, value in enumerate(hips_location): + set_curve_value(hips_curves[axis], frame, value) + changed_keys += 1 + for axis, value in enumerate(board_location): + set_curve_value(board_curves[axis], frame, value) + changed_keys += 1 + changed_curves += 6 + + for frame in range(start, end + 1): + bpy.context.scene.frame_set(frame) + bpy.context.view_layer.update() + anchor_position = armature.pose.bones[anchor_name].matrix.translation + maximum_anchor_travel = max( + maximum_anchor_travel, + abs(anchor_position.x), + abs(anchor_position.y), + ) + for source_vector, (toe_name, target_name) in zip( + source_target_vectors[frame], TOE_TARGETS + ): + output_vector = ( + armature.pose.bones[toe_name].matrix.translation + - armature.pose.bones[target_name].matrix.translation + ) + maximum_target_vector_error = max( + maximum_target_vector_error, + (output_vector - source_vector).length, + ) + finally: + armature.animation_data.action = None + for track, mute in zip(tracks, track_mutes): + track.mute = mute + + return ( + changed_curves, + changed_keys, + maximum_anchor_travel, + maximum_target_vector_error, + root_motion, + ) + + +def join_skinned_meshes() -> tuple[list[str], list[str]]: + meshes = sorted( + (obj for obj in bpy.data.objects if obj.type == "MESH"), + key=lambda obj: obj.name, + ) + source_names = [obj.name for obj in meshes] + if len(meshes) > 1: + bpy.ops.object.select_all(action="DESELECT") + for mesh in meshes: + mesh.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + bpy.ops.object.join() + meshes[0].name = "Skate3_SkaterAndBoard" + return source_names, sorted( + obj.name for obj in bpy.data.objects if obj.type == "MESH" + ) + + +def main() -> None: + output, manifest, root_motion_path = parse_args() + output.parent.mkdir(parents=True, exist_ok=True) + manifest.parent.mkdir(parents=True, exist_ok=True) + root_motion_path.parent.mkdir(parents=True, exist_ok=True) + + armature_objects = [obj for obj in bpy.data.objects if obj.type == "ARMATURE"] + if len(armature_objects) != 1: + raise RuntimeError( + f"Expected exactly one armature, found {len(armature_objects)}" + ) + ( + changed_curves, + changed_keys, + maximum_anchor_travel, + maximum_target_vector_error, + root_motion, + ) = bake_root_space_in_place(armature_objects[0]) + if maximum_anchor_travel > 5.0e-5: + raise RuntimeError( + "Root-space in-place bake left horizontal anchor travel: " + f"{maximum_anchor_travel:.9g}" + ) + if maximum_target_vector_error > 5.0e-5: + raise RuntimeError( + "Board-space in-place bake changed toe-target registration: " + f"{maximum_target_vector_error:.9g}" + ) + source_meshes, meshes = join_skinned_meshes() + actions = sorted(action.name for action in bpy.data.actions) + armatures = sorted( + obj.name for obj in bpy.data.objects if obj.type == "ARMATURE" + ) + + bpy.ops.export_scene.gltf( + filepath=str(output), + export_format="GLB", + export_animations=True, + export_skins=True, + export_morph=True, + export_apply=False, + export_yup=True, + ) + + root_motion_path.write_text( + json.dumps( + { + "schema": 1, + "coordinate_system": "bevy_y_up_forward_positive_z", + "actions": root_motion, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + manifest.write_text( + "\n".join( + [ + f"output={output.name}", + f"root_motion={root_motion_path.name}", + f"actions={len(actions)}", + f"armatures={len(armatures)}", + f"meshes={len(meshes)}", + f"source_meshes={len(source_meshes)}", + f"root_motion_curves_locked={changed_curves}", + f"root_motion_keys_locked={changed_keys}", + f"maximum_anchor_horizontal_travel={maximum_anchor_travel:.9g}", + ( + "maximum_toe_target_vector_error=" + f"{maximum_target_vector_error:.9g}" + ), + *[f"action={name}" for name in actions], + *[f"armature={name}" for name in armatures], + *[f"source_mesh={name}" for name in source_meshes], + *[f"mesh={name}" for name in meshes], + ] + ) + + "\n", + encoding="utf-8", + ) + print( + "SKATE3_BEVY_PRIVATE_EXPORT " + f"output={output} actions={len(actions)} meshes={len(meshes)} " + f"locked_curves={changed_curves} locked_keys={changed_keys} " + f"anchor_travel={maximum_anchor_travel:.9g} " + f"toe_target_error={maximum_target_vector_error:.9g}" + ) + + +main() diff --git a/tools/extract_default_skater.py b/tools/extract_default_skater.py new file mode 100644 index 00000000..5f39c956 --- /dev/null +++ b/tools/extract_default_skater.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""Recover the retail Xbox 360 fallback skater into ignored private paths. + +This script never modifies retail sources. It validates the fallback recipe, +selects the exact LOD0 modular pieces recorded in the checked-in manifest, +decodes every referenced RX2 texture with the user's proven local tooling, and +writes only to assets/private or work/private-assets. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import math +from pathlib import Path +import shutil +import struct +import sys +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_MANIFEST = Path(__file__).with_name("default_skater_retail_manifest.json") +DEFAULT_WORK = PROJECT_ROOT / "work" / "private-assets" / "default_skater" +DEFAULT_PRIVATE = PROJECT_ROOT / "assets" / "private" / "default_skater" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest().upper() + + +def require_file(path: Path, label: str) -> Path: + if not path.is_file(): + raise RuntimeError(f"{label} is missing: {path}") + return path + + +def find_exact_file(candidates: list[Path], expected_hash: str, label: str) -> Path: + found = [] + for candidate in candidates: + if candidate.is_file(): + actual = sha256(candidate) + found.append(f"{candidate} ({actual})") + if actual == expected_hash: + return candidate + detail = "\n ".join(found) if found else "" + raise RuntimeError( + f"No authorized local {label} matched SHA-256 {expected_hash}.\n" + f"Candidates:\n {detail}" + ) + + +def be_u32(data: bytes, offset: int) -> int: + return struct.unpack_from(">I", data, offset)[0] + + +def parse_fallback_recipe(path: Path, payload_offset: int) -> dict[str, Any]: + wrapped = path.read_bytes() + data = wrapped[payload_offset:] + name_len = data[7] + name = data[8 : 8 + name_len].decode("ascii") + recipe_type = data[0x0B + name_len] + full_payload = data[0x13 + name_len] == 2 + index = 0x18 + name_len + list_count = data[index - 1] + lists: dict[str, list[dict[str, Any]]] = {} + + for _ in range(list_count): + list_name_len = data[index + 3] + list_name = data[index + 4 : index + 4 + list_name_len].decode("ascii") + index += 8 + list_name_len + asset_count = data[index - 1] + assets = [] + for _ in range(asset_count): + asset_id = data[index : index + 8].hex() + model_count = data[index + 11] + index += 12 + models = [] + for _ in range(model_count): + block = data[index : index + 0x25] + if len(block) != 0x25: + raise RuntimeError("Fallback recipe ended inside a model block") + model = { + "lod": block[0], + "model_arena_id": block[1:9].hex(), + "model_id": block[9:17].hex(), + "material_id": block[0x19 : 0x21].hex(), + "textures": {}, + } + texture_loops = block[0x14] + texture_count = block[-1] + index += 0x25 + for _ in range(texture_count): + channel_len = data[index + 3] + channel = data[index + 4 : index + 4 + channel_len].decode( + "ascii" + ) + texture_id = data[ + index + 4 + channel_len : index + 12 + channel_len + ].hex() + model["textures"][channel] = texture_id + index += 12 + channel_len + # Female rostrals can carry additional material loops. They are + # retained in the format even though this selected male preset + # uses only the first material instance. + for _ in range(max(0, texture_loops - 1)): + index += 0x10 + extra_count = data[index - 1] + for _ in range(extra_count): + channel_len = data[index + 3] + index += 12 + channel_len + models.append(model) + assets.append({"asset_id": asset_id, "models": models}) + lists[list_name] = assets + + gender = None + body_mods: list[float] = [] + if full_payload: + index += 5 + gender = data[index] + index += 9 + rgb_count = data[index - 1] + index += rgb_count * 0x2C + index += 8 + graphic_count = data[index - 1] + for _ in range(graphic_count): + url_len = data[index + 3] + index += 5 + 24 + url_len + index += 1 + index += 4 + body_mods = list(struct.unpack_from(">19f", data, index)) + + return { + "name": name, + "recipe_type": recipe_type, + "full_payload": full_payload, + "gender": gender, + "asset_lists": lists, + "body_mods": body_mods, + } + + +def assert_recipe(parsed: dict[str, Any], manifest: dict[str, Any]) -> None: + preset = manifest["preset"] + if parsed["name"] != preset["recipe_name"]: + raise RuntimeError( + f"Recipe name mismatch: {parsed['name']} != {preset['recipe_name']}" + ) + if parsed["recipe_type"] != preset["recipe_type"]: + raise RuntimeError("Fallback recipe is not a CreateACharacter recipe") + if parsed["gender"] != preset["gender"]: + raise RuntimeError( + f"Fallback gender mismatch: {parsed['gender']} != {preset['gender']}" + ) + + selected_slots = {component["slot"] for component in manifest["components"]} + actual_slots = set(parsed["asset_lists"]) + missing_slots = sorted(selected_slots - actual_slots) + if missing_slots: + raise RuntimeError(f"Fallback recipe is missing slots: {missing_slots}") + + for component in manifest["components"]: + slot = component["slot"] + matches = [ + asset + for asset in parsed["asset_lists"][slot] + if asset["asset_id"] == component["asset_id"] + ] + if len(matches) != 1: + raise RuntimeError( + f"{slot} expected asset {component['asset_id']}, found {len(matches)}" + ) + models = matches[0]["models"] + if len(models) < 2: + raise RuntimeError(f"{slot} is missing its retail LOD pair") + high, low = models[0], models[1] + expected_high = { + "model_id": component["model_id"], + "model_arena_id": component["model_arena_id"], + "material_id": component["material_id"], + "textures": component["textures"], + } + for key, expected in expected_high.items(): + if high[key] != expected: + raise RuntimeError( + f"{slot} {key} mismatch:\n" + f" recipe={high[key]}\n expected={expected}" + ) + if low["model_id"] != component["low_model_id"]: + raise RuntimeError(f"{slot} low LOD model ID changed") + if low["model_arena_id"] != component["low_model_arena_id"]: + raise RuntimeError(f"{slot} low LOD arena ID changed") + + body = parsed["body_mods"] + if len(body) != 19: + raise RuntimeError(f"Expected 19 body modifiers, found {len(body)}") + if abs(body[0] - preset["body_mods"]["skinniness"]) > 1.0e-7: + raise RuntimeError("Fallback skinniness changed") + if abs(body[1] - preset["body_mods"]["fatness"]) > 1.0e-7: + raise RuntimeError("Fallback fatness changed") + expected_face = preset["body_mods"]["face_fields"] + if any(abs(value - expected_face) > 1.0e-7 for value in body[2:]): + raise RuntimeError("Fallback face/body modifier baseline changed") + + +def import_rx2_parser(utt_root: Path): + module_path = require_file(utt_root / "rx2_parser.py", "RX2 texture decoder") + spec = importlib.util.spec_from_file_location("skate3_local_rx2_parser", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load RX2 decoder: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def decode_texture(module, source: Path, destination: Path) -> dict[str, Any]: + parsed = module.parse_rx2(source) + if len(parsed.textures) != 1: + raise RuntimeError( + f"Expected one texture in {source.name}, found {len(parsed.textures)}" + ) + texture = parsed.textures[0] + destination.parent.mkdir(parents=True, exist_ok=True) + texture.save_png(destination) + return { + "width": texture.width, + "height": texture.height, + "format_id": texture.fmt_id, + "format": texture.fmt_name, + "png_sha256": sha256(destination), + "warnings": list(parsed.warnings), + } + + +def build_composite_images( + manifest: dict[str, Any], decoded_root: Path, output_root: Path +) -> dict[str, dict[str, str]]: + from PIL import Image, ImageChops + + def reconstruct_dxt5nm(source: Image.Image) -> Image.Image: + """Convert Skate 3's DXT5nm `.ag` storage to a glTF RGB normal.""" + packed = source.convert("RGBA") + converted: list[tuple[int, int, int]] = [] + pixels = ( + packed.get_flattened_data() + if hasattr(packed, "get_flattened_data") + else packed.getdata() + ) + for _red, green, _blue, alpha in pixels: + x = alpha / 127.5 - 1.0 + y = green / 127.5 - 1.0 + xy_length_squared = x * x + y * y + if xy_length_squared > 1.0: + inverse_length = 1.0 / math.sqrt(xy_length_squared) + x *= inverse_length + y *= inverse_length + z = 0.0 + else: + z = math.sqrt(1.0 - xy_length_squared) + converted.append( + ( + round((x * 0.5 + 0.5) * 255.0), + round((y * 0.5 + 0.5) * 255.0), + round((z * 0.5 + 0.5) * 255.0), + ) + ) + result = Image.new("RGB", packed.size) + result.putdata(converted) + return result + + outputs: dict[str, dict[str, str]] = {} + for component in manifest["components"]: + slot = component["slot"] + textures = component["textures"] + diffuse = Image.open(decoded_root / f"{textures['diffuse']}.png").convert( + "RGBA" + ) + # The decal/decal2 references are shader inputs, not unconditional + # overlays. The fallback recipe has no active graphic URL, so baking + # those generic template maps into base color would create false face + # markings and logos. Preserve them as decoded maps while using the + # selected diffuse as the retail base-color layer. + result = diffuse + if slot == "Hair" and "alpha" in textures: + alpha = Image.open(decoded_root / f"{textures['alpha']}.png").convert("RGBA") + if alpha.size != result.size: + alpha = alpha.resize(result.size, Image.Resampling.LANCZOS) + # Retail hair alpha files are A8-like masks. Prefer their alpha + # channel, falling back to luminance when the decoder reports a + # fully opaque channel. + mask = alpha.getchannel("A") + if mask.getextrema() == (255, 255): + mask = alpha.convert("L") + result.putalpha(ImageChops.multiply(result.getchannel("A"), mask)) + destination = output_root / f"{slot}_base_color.png" + destination.parent.mkdir(parents=True, exist_ok=True) + result.save(destination, "PNG") + outputs[slot] = {"base_color_sha256": sha256(destination)} + + # The character shaders sample DXT5 normals as `.ag`: X is stored + # in alpha and Y in green, then Z is reconstructed. Feeding the + # decoded grayscale RGB directly to glTF produces near-horizontal + # normals and harsh black/white mottling in Bevy. + normal_id = textures.get("normal") + if normal_id: + normal = reconstruct_dxt5nm( + Image.open(decoded_root / f"{normal_id}.png") + ) + normal_destination = output_root / f"{slot}_normal.png" + normal.save(normal_destination, "PNG") + outputs[slot]["normal_sha256"] = sha256(normal_destination) + + specular_id = textures.get("specular") + if specular_id: + specular = Image.open( + decoded_root / f"{specular_id}.png" + ).convert("L") + roughness = ImageChops.invert(specular) + roughness_destination = output_root / f"{slot}_roughness.png" + roughness.save(roughness_destination, "PNG") + outputs[slot]["roughness_sha256"] = sha256(roughness_destination) + return outputs + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument( + "--owned-data-root", + type=Path, + default=PROJECT_ROOT / "work" / "private-assets" / "owned-game", + ) + parser.add_argument( + "--utt-root", + type=Path, + default=PROJECT_ROOT / "tools" / "vendor" / "utt", + ) + parser.add_argument("--work-root", type=Path, default=DEFAULT_WORK) + parser.add_argument("--private-root", type=Path, default=DEFAULT_PRIVATE) + parser.add_argument("--verify-only", action="store_true") + args = parser.parse_args() + + manifest = json.loads(require_file(args.manifest, "retail manifest").read_text()) + recipe_hash = manifest["preset"]["recipe_sha256"] + recipe = require_file( + args.owned_data_root + / "data" + / "cacrecipes" + / "SavedRecipeFallbackMale.bin", + "ISO-extracted SavedRecipeFallbackMale.bin", + ) + if sha256(recipe) != recipe_hash: + raise RuntimeError("Fallback recipe hash changed") + if recipe.stat().st_size != manifest["preset"]["recipe_size"]: + raise RuntimeError("Fallback recipe size changed") + parsed = parse_fallback_recipe(recipe, manifest["preset"]["payload_offset"]) + assert_recipe(parsed, manifest) + + utt_root = args.utt_root + cac_root = ( + args.owned_data_root / "data" / "content" / "createacharacter" + ) + model_root = cac_root / "model" / "cas_db" + texture_root = cac_root / "texture" + + selected_models = args.work_root / "selected" / "models" + selected_raw_textures = args.work_root / "selected" / "textures" + decoded_root = args.private_root / "textures" / "decoded" + composite_root = args.private_root / "textures" / "materials" + generated: dict[str, Any] = { + "schema": 1, + "normal_encoding": "dxt5nm-ag-to-gltf-rgb-v1", + "body_mods": parsed["body_mods"], + "recipe": str(recipe), + "recipe_sha256": recipe_hash, + "archive": "owned-retail-createacharacter.big", + "archive_sha256": manifest["archive"]["sha256"], + "models": {}, + "textures": {}, + } + + rx2_parser = import_rx2_parser(utt_root) + all_texture_ids = sorted( + { + texture_id + for component in manifest["components"] + for texture_id in component["textures"].values() + } + ) + for component in manifest["components"]: + slot = component["slot"] + model_id = component["model_id"] + source = require_file( + model_root / slot / f"0x{model_id}.rx2", f"{slot} LOD0 model" + ) + actual_hash = sha256(source) + expected_hash = manifest["model_sha256"][model_id] + if actual_hash != expected_hash: + raise RuntimeError( + f"{slot} model hash mismatch: {actual_hash} != {expected_hash}" + ) + destination = selected_models / slot / source.name + if not args.verify_only: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + generated["models"][slot] = { + "id": model_id, + "sha256": actual_hash, + "bytes": source.stat().st_size, + } + + for texture_id in all_texture_ids: + source = require_file( + texture_root / f"0x{texture_id}.rx2", f"texture {texture_id}" + ) + actual_hash = sha256(source) + expected_hash = manifest["texture_sha256"][texture_id] + if actual_hash != expected_hash: + raise RuntimeError( + f"Texture {texture_id} hash mismatch: " + f"{actual_hash} != {expected_hash}" + ) + raw_destination = selected_raw_textures / source.name + if not args.verify_only: + raw_destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, raw_destination) + png_destination = decoded_root / f"{texture_id}.png" + metadata = decode_texture(rx2_parser, source, png_destination) + metadata.update( + { + "rx2_sha256": actual_hash, + "rx2_bytes": source.stat().st_size, + } + ) + generated["textures"][texture_id] = metadata + + generated["material_images"] = build_composite_images( + manifest, decoded_root, composite_root + ) + stable_assembly = { + key: value + for key, value in generated.items() + if key not in {"recipe", "archive"} + } + generated["assembly_sha256"] = hashlib.sha256( + json.dumps(stable_assembly, sort_keys=True).encode("utf-8") + ).hexdigest().upper() + generated_path = args.work_root / "default_skater.generated.json" + generated_path.parent.mkdir(parents=True, exist_ok=True) + generated_path.write_text( + json.dumps(generated, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print( + "DEFAULT_SKATER_EXTRACTION_OK " + f"models={len(generated['models'])} " + f"textures={len(generated['textures'])} " + f"assembly={generated['assembly_sha256']} " + f"verify_only={args.verify_only}" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"DEFAULT_SKATER_EXTRACTION_FAILED: {error}", file=sys.stderr) + raise diff --git a/tools/owned_game/NOTICE.md b/tools/owned_game/NOTICE.md new file mode 100644 index 00000000..8df5f35d --- /dev/null +++ b/tools/owned_game/NOTICE.md @@ -0,0 +1,10 @@ +# Provenance + +The EB BIG v3 index and RefPack readers in this directory are adapted from +original MIT-licensed Custom Engine Layer tooling: + +Copyright (c) 2026 Skate 3 Custom Engine Layer contributors + +See `../vendor/university/LICENSE-PROJECT.md` for the complete MIT +license notice. No Electronic Arts executable, archive utility, or game data +is included. diff --git a/tools/owned_game/__init__.py b/tools/owned_game/__init__.py new file mode 100644 index 00000000..1bf51122 --- /dev/null +++ b/tools/owned_game/__init__.py @@ -0,0 +1 @@ +"""Source-only extraction helpers for user-supplied Skate 3 game data.""" diff --git a/tools/owned_game/big.py b/tools/owned_game/big.py new file mode 100644 index 00000000..9af625fa --- /dev/null +++ b/tools/owned_game/big.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import zlib +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath + +from .binary import FormatError, Reader, align +from .refpack import decompress as decompress_refpack + + +@dataclass(frozen=True) +class BigEntry: + index: int + path: str + offset: int + stored_size: int + unpacked_size: int + compression: int + + +def _decompress_chunkref(data: bytes, expected_size: int) -> bytes: + reader = Reader(data, "chunkref") + if reader.bytes(0, 8) != b"chunkref" or reader.u32be(8) != 2: + raise FormatError("invalid chunkref header") + total_size = reader.u32be(12) + chunk_size = reader.u32be(16) + chunk_count = reader.u32be(20) + alignment = reader.u32be(24) + if ( + total_size != expected_size + or chunk_size == 0 + or chunk_count == 0 + or alignment == 0 + or alignment > 4096 + or alignment & (alignment - 1) + ): + raise FormatError("invalid chunkref dimensions") + + output = bytearray() + cursor = 28 + for _ in range(chunk_count): + payload = align(cursor + 8, alignment) + descriptor = payload - 8 + packed_size = reader.u32be(descriptor) + method = reader.u32be(descriptor + 4) + packed = reader.bytes(payload, packed_size) + expected_chunk = min(chunk_size, total_size - len(output)) + if method == 0: + decoded = packed + elif method == 2: + decoded = decompress_refpack(packed, expected_chunk) + elif method == 3: + try: + decoded = zlib.decompress(packed) + except zlib.error as error: + raise FormatError("chunkref zlib stream is invalid") from error + elif method == 4: + decoded = packed + else: + raise FormatError(f"unsupported chunkref method {method}") + if len(decoded) != expected_chunk: + raise FormatError( + f"chunkref chunk decoded to {len(decoded)} bytes; " + f"expected {expected_chunk}" + ) + output.extend(decoded) + cursor = payload + packed_size + + if len(output) != total_size: + raise FormatError( + f"chunkref produced {len(output)} bytes; expected {total_size}" + ) + return bytes(output) + + +class BigArchive: + """Streaming reader for the EB BIG v3 archives used by Skate 3.""" + + def __init__(self, path: Path): + self.path = Path(path).resolve() + self.file_size = self.path.stat().st_size + with self.path.open("rb") as stream: + header = stream.read(48) + if len(header) != 48: + raise FormatError(f"{self.path}: truncated BIG header") + header_reader = Reader(header, str(self.path)) + index_size = header_reader.u32be(12) + names_size = header_reader.u32be(16) + metadata_size = index_size + names_size + if metadata_size > self.file_size: + raise FormatError("BIG index/name blocks exceed file") + stream.seek(0) + self.metadata = stream.read(metadata_size) + self.entries = self._parse() + + def _parse(self) -> list[BigEntry]: + reader = Reader(self.metadata, str(self.path)) + if reader.u16be(0) != 0x4542: + raise FormatError("not an EB BIG archive") + version = reader.u16be(2) + if version != 3: + raise FormatError(f"unsupported EB BIG version {version}") + count = reader.u32be(4) + if count > 1_000_000: + raise FormatError(f"unreasonable BIG entry count {count}") + flags = reader.u16be(8) + shift = reader.u8(10) + if shift > 31: + raise FormatError(f"invalid BIG alignment shift {shift}") + index_size = reader.u32be(12) + names_size = reader.u32be(16) + name_record_size = reader.u8(20) + directory_record_size = reader.u8(21) + if name_record_size < 3 or directory_record_size < 2: + raise FormatError("invalid BIG name record sizing") + + entry_size = 20 if flags & 1 else 16 + entries_start = 48 + entries_bytes = entry_size * count + compression_start = entries_start + align(entries_bytes, 16) + names_start = index_size + directories_start = names_start + align(name_record_size * count, 16) + reader.require(entries_start, entries_bytes) + reader.require(compression_start, count) + reader.require(names_start, name_record_size * count) + + directories: list[str] = [] + directory_bytes_end = index_size + names_size + cursor = directories_start + while cursor + directory_record_size <= directory_bytes_end: + directories.append( + reader.bytes(cursor, directory_record_size) + .split(b"\0", 1)[0] + .decode("utf-8", errors="replace") + ) + cursor += directory_record_size + + result: list[BigEntry] = [] + for index in range(count): + entry_offset = entries_start + index * entry_size + file_offset = reader.u32be(entry_offset) << shift + declared_stored = reader.u32be(entry_offset + 4) + unpacked_size = reader.u32be(entry_offset + 8) or declared_stored + stored_size = declared_stored or unpacked_size + compression = reader.u8(compression_start + index) + name_record = names_start + index * name_record_size + directory_index = reader.u16be(name_record) + filename = ( + reader.bytes(name_record + 2, name_record_size - 2) + .split(b"\0", 1)[0] + .decode("utf-8", errors="replace") + ) + directory = ( + directories[directory_index] + if directory_index < len(directories) + else "." + ) + archive_path = filename if directory in ("", ".") else f"{directory}/{filename}" + if file_offset > self.file_size - stored_size: + raise FormatError(f"{archive_path}: payload exceeds BIG archive") + result.append( + BigEntry( + index=index, + path=archive_path.replace("\\", "/"), + offset=file_offset, + stored_size=stored_size, + unpacked_size=unpacked_size, + compression=compression, + ) + ) + return result + + def manifest(self) -> list[dict]: + return [asdict(entry) for entry in self.entries] + + def read(self, entry: BigEntry) -> bytes: + with self.path.open("rb") as stream: + stream.seek(entry.offset) + packed = stream.read(entry.stored_size) + if len(packed) != entry.stored_size: + raise FormatError(f"{entry.path}: truncated BIG payload") + if entry.compression == 0: + decoded = packed + elif entry.compression == 1: + decoded = decompress_refpack(packed, entry.unpacked_size) + elif entry.compression in (2, 3, 4): + decoded = _decompress_chunkref(packed, entry.unpacked_size) + else: + raise FormatError( + f"{entry.path}: unsupported BIG compression {entry.compression}" + ) + if len(decoded) != entry.unpacked_size: + raise FormatError( + f"{entry.path}: decoded to {len(decoded)} bytes; " + f"expected {entry.unpacked_size}" + ) + return decoded + + @staticmethod + def safe_relative(path: str) -> Path: + pure = PurePosixPath(path.replace("\\", "/")) + if pure.is_absolute() or not pure.parts or any( + part in ("", ".", "..") for part in pure.parts + ): + raise FormatError(f"unsafe archive path {path!r}") + if ":" in pure.parts[0]: + raise FormatError(f"unsafe archive path {path!r}") + return Path(*pure.parts) + + def extract_entries( + self, + entries: list[BigEntry], + output: Path, + overwrite: bool = False, + ) -> list[Path]: + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + for entry in entries: + relative = self.safe_relative(entry.path) + target = (output / relative).resolve() + if output != target and output not in target.parents: + raise FormatError(f"archive path escapes output: {entry.path!r}") + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and not overwrite: + raise FileExistsError(f"refusing to overwrite {target}") + target.write_bytes(self.read(entry)) + written.append(target) + return written diff --git a/tools/owned_game/binary.py b/tools/owned_game/binary.py new file mode 100644 index 00000000..0cfec8ae --- /dev/null +++ b/tools/owned_game/binary.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import struct + + +class FormatError(ValueError): + """Raised when an owned input is malformed or outside supported bounds.""" + + +class Reader: + def __init__(self, data: bytes, label: str = "input"): + self.data = data + self.label = label + + def require(self, offset: int, size: int) -> None: + if offset < 0 or size < 0 or offset > len(self.data) - size: + raise FormatError( + f"{self.label}: range {offset:#x}+{size:#x} is outside " + f"{len(self.data):#x} bytes" + ) + + def bytes(self, offset: int, size: int) -> bytes: + self.require(offset, size) + return self.data[offset : offset + size] + + def u8(self, offset: int) -> int: + self.require(offset, 1) + return self.data[offset] + + def u16be(self, offset: int) -> int: + self.require(offset, 2) + return struct.unpack_from(">H", self.data, offset)[0] + + def u32be(self, offset: int) -> int: + self.require(offset, 4) + return struct.unpack_from(">I", self.data, offset)[0] + + +def align(value: int, alignment: int) -> int: + return (value + alignment - 1) & ~(alignment - 1) diff --git a/tools/owned_game/refpack.py b/tools/owned_game/refpack.py new file mode 100644 index 00000000..7ac8f87f --- /dev/null +++ b/tools/owned_game/refpack.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from .binary import FormatError + + +def _copy_backref(output: bytearray, distance: int, count: int) -> None: + if distance <= 0 or distance > len(output): + raise FormatError( + f"RefPack back-reference distance {distance} exceeds " + f"{len(output)} output bytes" + ) + for _ in range(count): + output.append(output[-distance]) + + +def decompress(data: bytes, expected_size: int | None = None) -> bytes: + """Decode an EA RefPack stream, including 0x10FB/0x90FB headers.""" + if len(data) < 2: + raise FormatError("RefPack stream is too short") + + position = 0 + header_size = None + if data[1] == 0xFB and data[0] in (0x10, 0x90): + if data[0] & 0x80: + if len(data) < 6: + raise FormatError("truncated extended RefPack header") + header_size = int.from_bytes(data[2:6], "big") + position = 6 + else: + if len(data) < 5: + raise FormatError("truncated RefPack header") + header_size = int.from_bytes(data[2:5], "big") + position = 5 + + if expected_size is None: + expected_size = header_size + elif header_size not in (None, expected_size): + raise FormatError( + f"RefPack header size {header_size} differs from archive size " + f"{expected_size}" + ) + + output = bytearray() + + def literal(count: int) -> None: + nonlocal position + if position + count > len(data): + raise FormatError("truncated RefPack literal") + output.extend(data[position : position + count]) + position += count + + while position < len(data): + control = data[position] + position += 1 + if control < 0x80: + if position >= len(data): + raise FormatError("truncated two-byte RefPack command") + b1 = data[position] + position += 1 + literal(control & 0x03) + distance = ((control & 0x60) << 3) + b1 + 1 + count = ((control >> 2) & 0x07) + 3 + _copy_backref(output, distance, count) + elif control < 0xC0: + if position + 2 > len(data): + raise FormatError("truncated three-byte RefPack command") + b1, b2 = data[position], data[position + 1] + position += 2 + literal(b1 >> 6) + distance = ((b1 & 0x3F) << 8) + b2 + 1 + _copy_backref(output, distance, (control & 0x3F) + 4) + elif control < 0xE0: + if position + 3 > len(data): + raise FormatError("truncated four-byte RefPack command") + b1, b2, b3 = data[position], data[position + 1], data[position + 2] + position += 3 + literal(control & 0x03) + distance = ((control & 0x10) << 12) + (b1 << 8) + b2 + 1 + count = ((control & 0x0C) << 6) + b3 + 5 + _copy_backref(output, distance, count) + elif control < 0xFC: + literal(((control & 0x1F) << 2) + 4) + else: + literal(control & 0x03) + break + + if expected_size is not None and len(output) > expected_size: + raise FormatError("RefPack output exceeds declared size") + + if expected_size is not None and len(output) != expected_size: + raise FormatError( + f"RefPack produced {len(output)} bytes; expected {expected_size}" + ) + return bytes(output) diff --git a/tools/requirements-setup.txt b/tools/requirements-setup.txt new file mode 100644 index 00000000..7f250c26 --- /dev/null +++ b/tools/requirements-setup.txt @@ -0,0 +1,3 @@ +pyinstaller==6.19.0 +numpy==2.2.6 +Pillow==11.3.0 diff --git a/tools/setup.py b/tools/setup.py new file mode 100644 index 00000000..4d7adffc --- /dev/null +++ b/tools/setup.py @@ -0,0 +1,71 @@ +"""First-run setup window and packaged Python conversion worker.""" +from pathlib import Path +import argparse,os,queue,runpy,sys,threading,traceback + +ROOT=Path(getattr(sys,'_MEIPASS',Path(__file__).resolve().parents[1])) +sys.path.insert(0,str(ROOT)) + +def main(): + if len(sys.argv)>2 and sys.argv[1]=='--task': + script=Path(sys.argv[2]) + if not script.is_absolute():script=ROOT/script + script=script.resolve() + if not script.is_relative_to((ROOT/'tools').resolve()):raise RuntimeError('Invalid conversion script') + sys.path.insert(0,str(script.parent)) + sys.argv=[str(script),*sys.argv[3:]] + runpy.run_path(str(script),run_name='__main__') + return 0 + parser=argparse.ArgumentParser() + parser.add_argument('--base',type=Path,required=True) + parser.add_argument('--game-exe',type=Path,required=True) + args=parser.parse_args() + import tkinter as tk + from tkinter import filedialog,messagebox,ttk + from tools.asset_pipeline.install import install + window=tk.Tk() + window.title('Skate 3 Rust Engine setup') + window.geometry('660x390');window.resizable(False,False) + icon=ROOT/'docs/images/skating-crab.ico' + if icon.is_file():window.iconbitmap(str(icon)) + frame=ttk.Frame(window,padding=24);frame.pack(fill='both',expand=True) + ttk.Label(frame,text='Set up Skate 3 Rust Engine',font=('Segoe UI',20)).pack(anchor='w',pady=(0,16)) + ttk.Label(frame,text='Select your Xbox 360 Skate 3 ISO.\nYour game files stay on this PC. Setup downloads conversion tools,\nthen prepares the skater, animations and all disc districts.\n\nMap conversion can take a long time and needs substantial free disk space.', + font=('Segoe UI',11),justify='left').pack(anchor='w') + status=tk.StringVar(value='Choose your ISO to begin.') + ttk.Label(frame,textvariable=status,wraplength=600).pack(anchor='w',pady=(18,8)) + progress=ttk.Progressbar(frame,mode='indeterminate');progress.pack(fill='x') + messages=queue.Queue();running=False;success=False + def start(): + nonlocal running + iso=filedialog.askopenfilename(parent=window,title='Select your Skate 3 Xbox 360 ISO',filetypes=[('Xbox 360 ISO','*.iso')]) + if not iso:return + button.config(state='disabled');running=True;progress.start() + def work(): + try: + install(Path(iso),args.base,args.game_exe,lambda text:messages.put(('progress',text))) + messages.put(('done','Ready')) + except Exception as error: + args.base.mkdir(parents=True,exist_ok=True) + (args.base/'setup-error.log').write_text(traceback.format_exc(),encoding='utf-8') + messages.put(('error',str(error))) + threading.Thread(target=work,daemon=True).start() + def close(): + if running: + messagebox.showinfo('Setup running','Wait for the current conversion to finish. Your original ISO is not modified.',parent=window) + else:window.destroy() + button=ttk.Button(frame,text='Select Skate 3 ISO',command=start);button.pack(anchor='e',pady=18) + def poll(): + nonlocal running,success + while not messages.empty(): + kind,text=messages.get_nowait();status.set(text) + if kind=='done': + running=False;success=True;progress.stop();window.destroy();return + if kind=='error': + running=False;progress.stop();button.config(state='normal') + messagebox.showerror('Setup could not finish',text+'\n\nDetails: '+str(args.base/'setup-error.log'),parent=window) + window.after(100,poll) + window.protocol('WM_DELETE_WINDOW',close) + window.after(100,poll);window.mainloop() + return 0 if success else 2 + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tools/vendor/skate3_anim/PROVENANCE.md b/tools/vendor/skate3_anim/PROVENANCE.md new file mode 100644 index 00000000..5db56627 --- /dev/null +++ b/tools/vendor/skate3_anim/PROVENANCE.md @@ -0,0 +1,14 @@ +# Provenance + +`abin_importer.py` and `rx2_skeleton.py` are pinned from +`https://github.com/chasmlol/skate-3-trick-toolkit` commit +`85d9d679bc35462ce66fdbba95007b56a94579f0`. + +`blender_rx2_abin_export.py` is the project-maintained extension of that +exporter (SHA-256 +`58BF997204228E6C25942AFCB7BACE48E19BD478EBBD2E2E8C83695BB8417363`). +It adds a second ABIN source so the established builder can append the exact +OffBoard clips to the same armature and action bank. + +They decode animation and skeleton files supplied locally by the user. No +retail payload is included. diff --git a/tools/vendor/skate3_anim/abin_importer.py b/tools/vendor/skate3_anim/abin_importer.py new file mode 100644 index 00000000..d7f0d32d --- /dev/null +++ b/tools/vendor/skate3_anim/abin_importer.py @@ -0,0 +1,1358 @@ +""" +Skate 3 ABIN Animation Importer for Blender +Platform: PS3 (big-endian) / X360 (big-endian) - EA ANT AnimCodecs 1.01.00 + +VBR codec decoder verified against: + - EA AnimCodecs vbrdecompressor.cpp / vbranimation.cpp source + - Reverse-engineered structs from animtest.cpp + - sk82_na_m.xex IDA decompilation (InitPerAnim / UnPackHeaderBits) + - Direct byte-level analysis of OnBoard.abin + +Supported: + - Multiple animations per .abin (hundreds of type=2 clips) + - VBR codec (0x00524256) bit-packed DCT + - RAW codec (0x00004452) for static poses + - Type 3 pose blocks, type 4 hierarchy, type 5 physics t-pose +""" + +from __future__ import annotations + +bl_info = { + "name": "Skate 3 ABIN Importer", + "version": (2, 3, 0), + "blender": (3, 6, 0), + "location": "File > Import > Skate 3 Animation (.abin)", + "description": "Import Skate 3 PS3 .abin animation clips onto a selected armature", + "category": "Import-Export", +} + +__VERSION__ = "3.0.0-animtest-port" +print(f"[ABIN] abin_importer loaded, version {__VERSION__}") + +import math +import struct +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +try: + import bpy +except ModuleNotFoundError: + bpy = None + + +# --------------------------------------------------------------------------- +# Canonical Skate 3 bone names. Decoded frames are keyed by absolute +# skeleton bone index (engine sqtOffset); index i -> BONE_NAMES[i]. +# --------------------------------------------------------------------------- + +BONE_NAMES: List[str] = [ + "TRAJECTORY", "HIPS", "SPINE", "SPINE1", "SPINE2", "SPINE3", + "NECK", "NECK1", "HEAD", + "RIGHTSHOULDER", "RIGHTARM", "RIGHTFOREARM", "RIGHTHAND", + "LEFTSHOULDER", "LEFTARM", "LEFTFOREARM", "LEFTHAND", + "RIGHTUPLEG", "RIGHTLEG", "RIGHTFOOT", "RIGHTTOEBASE", + "LEFTUPLEG", "LEFTLEG", "LEFTFOOT", "LEFTTOEBASE", + "SKATEBOARD_ROOT", + "TRUCK_FRONT", "RIGHT_WHEELFRONT", "LEFT_WHEELFRONT", + "TRUCK_BACK", "LEFT_WHEELBACK", "RIGHT_WHEELBACK", + "LEFTHANDTHUMB1", "LEFTHANDTHUMB2", "LEFTHANDTHUMB3", + "LEFTHANDINDEX1", "LEFTHANDINDEX2", "LEFTHANDINDEX3", + "LEFTHANDMIDDLE1", "LEFTHANDMIDDLE2", "LEFTHANDMIDDLE3", + "LEFTINHANDRING", "LEFTHANDRING1", "LEFTHANDRING2", "LEFTHANDRING3", + "LEFTINHANDPINKY", "LEFTHANDPINKY1", "LEFTHANDPINKY2", "LEFTHANDPINKY3", + "RIGHTHANDTHUMB1", "RIGHTHANDTHUMB2", "RIGHTHANDTHUMB3", + "RIGHTHANDINDEX1", "RIGHTHANDINDEX2", "RIGHTHANDINDEX3", + "RIGHTHANDMIDDLE1", "RIGHTHANDMIDDLE2", "RIGHTHANDMIDDLE3", + "RIGHTINHANDRING", "RIGHTHANDRING1", "RIGHTHANDRING2", "RIGHTHANDRING3", + "RIGHTINHANDPINKY", "RIGHTHANDPINKY1", "RIGHTHANDPINKY2", "RIGHTHANDPINKY3", + "RIGHTTOEBASE_REPARENTED", "LEFTTOEBASE_REPARENTED", + "RIGHTHAND_REPARENTED", "LEFTHAND_REPARENTED", + "RIGHTSHOULDERHLP", "RIGHTARMTWIST", "RIGHTFOREARMTWIST", "RIGHTFOREARMTWIST1", + "LEFTSHOULDERHLP", "LEFTARMTWIST", "LEFTFOREARMTWIST", "LEFTFOREARMTWIST1", + "RIGHTUPLEGHLP", "RIGHTUPLEGTWIST", "LEFTUPLEGHLP", "LEFTUPLEGTWIST", + "FACE", "OFFSET_JAW", "JAW", "OFFSET_CHIN", "CHIN", + "OFFSET_LOWERLIP", "OFFSET_LEFTLOWERLIP", "OFFSET_RIGHTLOWERLIP", + "OFFSET_TONGUE", "OFFSET_LEFTCHEEK", "OFFSET_LEFTEYE", "OFFSET_LEFTMOUTH", + "OFFSET_LEFTUPCHEEK", "OFFSET_LEFTUPPERLIP", + "OFFSET_RIGHTCHEEK", "OFFSET_RIGHTEYE", "OFFSET_RIGHTMOUTH", + "OFFSET_RIGHTUPCHEEK", "OFFSET_RIGHTUPPERLIP", "OFFSET_UPPERLIP", + "TONGUE", "OFFSET_TONGUETIP", + "LEFTLOWERLIP", "LOWERLIP", "RIGHTLOWERLIP", "TONGUETIP", + "LEFTCHEEK", "LEFTCREASE", "LEFTEYE", "LEFTINNEREYEBROW", + "LEFTLOWEYELID", "LEFTMOUTH", "LEFTNOSE", "LEFTOUTEREYEBROW", + "LEFTUPCHEEK", "LEFTUPEYELID", "LEFTUPPERLIP", + "RIGHTCHEEK", "RIGHTCREASE", "RIGHTEYE", "RIGHTINNEREYEBROW", + "RIGHTLOWEYELID", "RIGHTMOUTH", "RIGHTNOSE", "RIGHTOUTEREYEBROW", + "RIGHTUPCHEEK", "RIGHTUPEYELID", "RIGHTUPPERLIP", "UPPERLIP", +] +assert len(BONE_NAMES) == 131 + + +# --------------------------------------------------------------------------- +# Binary helpers (big-endian) +# --------------------------------------------------------------------------- + +def _u8(d, o): return d[o] +def _u16(d, o): return struct.unpack_from(">H", d, o)[0] +def _u32(d, o): return struct.unpack_from(">I", d, o)[0] +def _i16(d, o): return struct.unpack_from(">h", d, o)[0] +def _i32(d, o): return struct.unpack_from(">i", d, o)[0] +def _f32(d, o): return struct.unpack_from(">f", d, o)[0] + + +# FastString36 (EA's 6-char-per-u32 base-38 encoding) +_FS_SEED = 79235168 +_FS_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_" + +def _decode6(v: int) -> str: + out, seed = [], _FS_SEED + while v > 0 and seed > 0: + idx = v // seed + if idx == 0 or idx > 37: + break + out.append(_FS_CHARSET[idx - 1]) + v %= seed + seed //= 38 + return "".join(out) + +def _decode_name36(d: bytes, off: int) -> str: + return "".join(_decode6(_u32(d, off + i * 4)) for i in range(6)).rstrip("\x00") + + +# --------------------------------------------------------------------------- +# Block / clip / part dataclasses +# --------------------------------------------------------------------------- + +CODEC_VBR = 0x00524256 # "\x00RBV" big-endian = "VBR\0" little-endian +CODEC_RAW = 0x00004452 # "\x00\x00DR" +CODEC_TAG = 0x00000001 # type 4/5 marker codec + + +@dataclass +class BlockHeader: + offset: int + size: int + type_id: int + codec: int + name: str + + +@dataclass +class SkelPart: + """One skeleton per-part record (36 bytes, big-endian) at + skel_base + 32 + 8*num_bones + idx*36: + +0 u32 total_bone_count + +4 s32 sqt_offset (SQTData bone index this part writes to) + +10 u16 bone_id (engine matches RD partIDs against this; VBR + parts carry partID 0 so VBR uses positional) + """ + bone_id: int + sqt_offset: int + total_bone_count: int + + +@dataclass +class HierarchyData: + num_bones: int + has_traj: bool + num_parts: int + parents: List[int] + skel_base: int # file offset of HierarchyData (= block + 0x30) + parts: List[SkelPart] # per-part records, in skeleton order + + +@dataclass +class AnimPart: + abs_offset: int # file offset of the 16-byte AnimationPart + m_header: int # mHeader (packed bits) + csize: int # mCompressedDataSize + comp_hdr_rel: int # mCompressionHeader (offset from abs_offset) + comp_data_rel: int # mCompressedData (offset from abs_offset) + channel_count: int # ((mHeader >> 6) & 0x3F) + 1 + part_id: int # parts-table entry >> 24 (0 for VBR; RD carries id) + table_index: int # positional index in the parts table + + +@dataclass +class AnimClip: + header: BlockHeader + fps: float + num_frames: int + parts: List[AnimPart] + base_off: int + + +@dataclass +class Pose: + header: BlockHeader + parts: List[AnimPart] + + +@dataclass +class SQT: + scale: Tuple[float, float, float] = (1.0, 1.0, 1.0) + quat: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) # x,y,z,w + trans: Tuple[float, float, float] = (0.0, 0.0, 0.0) + + +# --------------------------------------------------------------------------- +# IDCT + quant tables (faithful port of VBRDecompressor::Init, animtest.cpp) +# --------------------------------------------------------------------------- + +def _build_idct_coef_tables() -> List[List[float]]: + """mIdctCoefTables[k][n] = cos((k + 0.5)*n*pi/8) * 0.25 ; [k][0] *= 0.5. + Row k = output sample (frame-in-block); DecompressFrameBlock uses + idctCoef = table[frameinBlk].""" + tbl: List[List[float]] = [] + for k in range(8): + row = [math.cos(((k + 0.5) * n * math.pi) / 8.0) * 0.25 for n in range(8)] + row[0] *= 0.5 + tbl.append(row) + return tbl + +_IDCT_COEF_TABLE = _build_idct_coef_tables() + + +def _build_initial_quant_tables() -> List[List[float]]: + """mInitialQuantizeTables[ch][i] = (ln(i+2)/32768) * (1 + ch*0.2), + ch in 0..255. The per-block quant table is then + quant[i] = init[i]*scale**2 + scale*(1/32768) + where scale = max(|dctMin|,|dctMax|) (rexglue runtime bias = 1/32768).""" + base = [math.log(float(i + 2)) / 32768.0 for i in range(8)] + # Init @0x82c1c78c accumulates the multiplier in float32 (v += 0.2f), it + # does not compute 1.0 + ch*0.2. Replicate the accumulation and rounding. + f32 = lambda x: struct.unpack("f", struct.pack("f", x))[0] + step = f32(0.2) + tables: List[List[float]] = [] + v31 = 1.0 + for _ in range(256): + tables.append([f32(base[i] * v31) for i in range(8)]) + v31 = f32(v31 + step) + return tables + +_INITIAL_QUANT_TABLES = _build_initial_quant_tables() + + +# --------------------------------------------------------------------------- +# ABIN file parser +# --------------------------------------------------------------------------- + +class AbinFile: + """Parses an EA ANT .abin database (self-contained: one HT_HIERARCHY + skeleton + many clips/poses authored against it). + + Outer: type=1 wrapper covering the file; inner block chain starts at + 0x30. Each block is a DataHeader (size@0, type@4, codec@8, name@0x10) + followed by its payload. type 2 = animation clip, 3 = pose, + 4 = hierarchy, 5 = physics t-pose (skipped).""" + + def __init__(self, data: bytes) -> None: + self.data = data + self.hierarchy: Optional[HierarchyData] = None + self.clips: List[AnimClip] = [] + self.poses: List[Pose] = [] + self._parse() + + def _parse(self) -> None: + ptr = 0x30 + while ptr + 0x30 <= len(self.data): + size = _u32(self.data, ptr) + if size == 0 or size > len(self.data) - ptr: + break + type_id = _u32(self.data, ptr + 4) + codec = _u32(self.data, ptr + 8) + name = _decode_name36(self.data, ptr + 0x10) + hdr = BlockHeader(ptr, size, type_id, codec, name) + if type_id == 4: + try: + self.hierarchy = self._parse_hierarchy(hdr) + except Exception as e: + print(f"[ABIN] Hierarchy parse failed: {e}") + elif type_id == 2: + try: + clip = self._parse_clip(hdr) + if clip is not None: + self.clips.append(clip) + except Exception as e: + print(f"[ABIN] Clip '{name}' parse failed: {e}") + elif type_id == 3: + try: + pose = self._parse_pose(hdr) + if pose is not None: + self.poses.append(pose) + except Exception as e: + print(f"[ABIN] Pose '{name}' parse failed: {e}") + # type_id 5 (phys t-pose) — skip + ptr += size + + def _aligned_data(self, blk: int) -> int: + """Engine's alignedData = (&hdr[1].mGUID + 3) & ~0xF. + sizeof(DataHeader)=0x28, offsetof(mGUID)=0x0C -> blk + 0x34 + 3, then + floor-aligned to 16. For 16-aligned blocks this equals blk + 0x30 + (matches loadAnimationDataBase's `mHierarchy + 0x30`).""" + return (blk + 0x34 + 3) & ~0xF + + def _parse_hierarchy(self, hdr: BlockHeader) -> HierarchyData: + skel = self._aligned_data(hdr.offset) + num_bones = _u16(self.data, skel + 0) + has_traj = _u16(self.data, skel + 2) != 0 + num_parts = _u32(self.data, skel + 4) + parents = [_i32(self.data, skel + 8 + i * 4) for i in range(num_bones)] + + # Per-part record table: base = skel + 32 + 8*num_bones, stride 36. + rec_base = skel + 32 + 8 * num_bones + parts: List[SkelPart] = [] + for p in range(num_parts): + rec = rec_base + p * 36 + total = _u32(self.data, rec + 0) + sqt = _i32(self.data, rec + 4) + bid = _u16(self.data, rec + 10) + parts.append(SkelPart(bid, sqt, total)) + return HierarchyData(num_bones, has_traj, num_parts, parents, skel, parts) + + def _read_parts(self, base: int, table_off: int, + max_entries: int) -> List[AnimPart]: + """Parts table: each entry is a big-endian u32: + high byte = partID (RD carries a real id; VBR is always 0) + low 24 bits = AnimationPart offset, relative to `base`(=alignedData) + AnimationPart (16 bytes, BE): mHeader, mCompressedDataSize, + mCompressionHeader, mCompressedData.""" + parts: List[AnimPart] = [] + + # The table is not always zero-terminated. In retail RAW clips the + # first AnimationPart can immediately follow the final table entry, + # so reading a fixed 64 entries interprets part bytes as more entries. + # The first entry gives us a hard upper bound: no table entry can + # overlap the first part it points at. + first_entry = _u32(self.data, base + table_off) + first_part_off = first_entry & 0xFFFFFF + if first_part_off > table_off: + table_capacity = (first_part_off - table_off) // 4 + max_entries = min(max_entries, table_capacity) + + for i in range(max_entries): + entry = _u32(self.data, base + table_off + i * 4) + off = entry & 0xFFFFFF + if off == 0: + break + p_abs = base + off + if p_abs + 0x10 > len(self.data): + break + m_hdr = _u32(self.data, p_abs) + if (m_hdr & 0xFFFFF000) == 0: # engine sanity: header sizing bits + break + csize = _u32(self.data, p_abs + 4) + hdr_rel = _u32(self.data, p_abs + 8) + data_rel = _u32(self.data, p_abs + 12) + ch_count = ((m_hdr >> 6) & 0x3F) + 1 + parts.append(AnimPart(p_abs, m_hdr, csize, hdr_rel, data_rel, + ch_count, entry >> 24, i)) + return parts + + def _parse_clip(self, hdr: BlockHeader) -> Optional[AnimClip]: + ad = self._aligned_data(hdr.offset) + fps = _f32(self.data, ad + 0x20) + frames_f = _f32(self.data, ad + 0x24) + if not (1.0 <= fps <= 240.0) or not (1.0 <= frames_f <= 10000.0): + return None + num_frames = max(1, int(round(frames_f))) + parts = self._read_parts(ad, table_off=0x38, max_entries=64) + if not parts: + return None + return AnimClip(hdr, fps, num_frames, parts, ad) + + def _parse_pose(self, hdr: BlockHeader) -> Optional[Pose]: + """Pose (type=3): alignedData[0] = u32 totalParts, then the parts + table at alignedData + 4 (engine `isPose` layout).""" + ad = self._aligned_data(hdr.offset) + total_parts = _u32(self.data, ad + 0) + cap = total_parts if 1 <= total_parts <= 64 else 64 + parts = self._read_parts(ad, table_off=0x04, max_entries=cap) + if not parts: + return None + return Pose(hdr, parts) + + +# --------------------------------------------------------------------------- +# VBR data structures (disk layout) +# --------------------------------------------------------------------------- + +@dataclass +class VBRChannelBlock: + """One channel-info entry (16 bytes on disk, big-endian): + +0 u16 NumEntPerFrm (animated joints this channel) + +2 u16 NumConstEntPerFrm (constant joints this channel) + +4 f32 Min + +8 f32 Max + +12 u8 Size (floats per joint: scale=3, quat=4, trans=3) + """ + num_ent: int + num_const: int + ch_min: float + ch_max: float + size: int + + +@dataclass +class VBRPartInfo: + """Parsed VBR part, mirroring AnimTest's InitPerAnim + UnPackHeaderBits.""" + num_frames: int + dct_min: float + dct_max: float + num_channels: int + palette_size: int + channels: List[VBRChannelBlock] + frame_block_sz: List[int] # per-block compressed byte size + # Derived (InitPerAnim) + block_starts: List[int] # per-channel start in the frame row + block_ends: List[int] + frame_offset: int # sum(num_ent * size) = bit-decoder bones + # UnPackHeaderBits output + const_values: List[float] # flat, channel-major (numConst*size each) + is_const: List[int] # one flag per joint, channel-major order + num_bits_off: int # bytes consumed (= ctrl-word table start) + mem_hdr_size: int # InitPerAnim return: ctrl table + framedata + # Absolute file offsets + comp_data_off: int # part + mCompressedData (compressed base) + channel_info_off: int # inline channel-info table + palette_off: int # inline constant palette + frame_block_sz_off: int # inline mFrameBlockSizes + + +def _compute_vbr_inline(data: bytes, hdr_off: int) -> Tuple[int, int]: + """Port of ComputeVBRInline. Returns (palette_off, channel_info_off). + palette_off = hdr + 16 + channel_info_off = align16(palette + 4*paletteSize + 2*ceil(numFrames/8)) + The engine floor-aligns an absolute 16-aligned pointer, so aligning the + file offset the same way is equivalent (blocks are 16-aligned).""" + palette_size = data[hdr_off + 13] + num_frames = _u16(data, hdr_off + 2) + palette_off = hdr_off + 16 + palette_bytes = 4 * palette_size + map_bytes = 2 * ((num_frames + 7) // 8) + ci = (palette_off + palette_bytes + map_bytes + 0xF) & ~0xF + return palette_off, ci + + +def parse_vbr_part(data: bytes, part: AnimPart) -> VBRPartInfo: + """Faithful port of InitPerAnim + UnPackHeaderBits + ComputeVBRInline. + + VBRDataHeaderValue (at part + mCompressionHeader), big-endian: + +0 u16 mConstChanMapSize + +2 u16 mNumFrames + +4 f32 mDctMin + +8 f32 mDctMax + +12 u8 mNumChannels + +13 u8 mConstantPaletteSize + Inline after it: palette[paletteSize] f32, frame-block-size u16 table, + 16-byte-aligned channel-info table (16 bytes per channel). + + Compressed data (at part + mCompressedData) begins with the memory header + consumed by UnPackHeaderBits: per-channel constant palette indices, then + the constant-channel-map RLE runs. + """ + hdr = part.abs_offset + part.comp_hdr_rel + dat = part.abs_offset + part.comp_data_rel + + const_map_sz = _u16(data, hdr + 0) + num_frames = _u16(data, hdr + 2) + dct_min = _f32(data, hdr + 4) + dct_max = _f32(data, hdr + 8) + num_ch = data[hdr + 12] + pal_size = data[hdr + 13] + + palette_off, ci_off = _compute_vbr_inline(data, hdr) + num_fblocks = (num_frames + 7) // 8 + fbs_off = hdr + 16 + pal_size * 4 + frame_block_sz = [_u16(data, fbs_off + i * 2) for i in range(num_fblocks)] + + channels: List[VBRChannelBlock] = [] + for i in range(num_ch): + o = ci_off + i * 16 + channels.append(VBRChannelBlock( + num_ent = _u16(data, o + 0), + num_const = _u16(data, o + 2), + ch_min = _f32(data, o + 4), + ch_max = _f32(data, o + 8), + size = data[o + 12], + )) + + # InitPerAnim: per-channel block start/end (cumulative num_ent*size), + # frame_offset = final end, capped to first 4 channels like the engine. + block_starts: List[int] = [] + block_ends: List[int] = [] + last_end = 0 + for i, c in enumerate(channels): + if i >= 4: + block_starts.append(last_end) + block_ends.append(last_end) + continue + start = 0 if i == 0 else last_end + last_end = start + c.num_ent * c.size + block_starts.append(start) + block_ends.append(last_end) + frame_offset = last_end + + # UnPackHeaderBits: constant palette decode + constant-channel-map RLE. + p = dat + const_values: List[float] = [] + for c in channels: + rng = c.ch_max - c.ch_min + n = c.size * c.num_const + for _ in range(n): + idx = data[p]; p += 1 + sca = _f32(data, palette_off + idx * 4) + const_values.append(sca * rng + c.ch_min) + + is_const: List[int] = [] + cur = 0 + for _ in range(const_map_sz): + count = data[p]; p += 1 + if count: + is_const.extend([cur] * count) + cur ^= 1 + if const_map_sz == 0 and num_ch > 0: + is_const.extend([0] * num_ch) + + num_bits_off = p - dat # bytes consumed + mem_hdr_size = num_bits_off + ((frame_offset * 4) & 0x7FFFFFFC) + + return VBRPartInfo( + num_frames=num_frames, dct_min=dct_min, dct_max=dct_max, + num_channels=num_ch, palette_size=pal_size, channels=channels, + frame_block_sz=frame_block_sz, + block_starts=block_starts, block_ends=block_ends, + frame_offset=frame_offset, + const_values=const_values, is_const=is_const, + num_bits_off=num_bits_off, mem_hdr_size=mem_hdr_size, + comp_data_off=dat, channel_info_off=ci_off, + palette_off=palette_off, frame_block_sz_off=fbs_off, + ) + + +def _vbr_decode_block(data: bytes, p_comp: int, ctrl_off: int, + bone_count: int) -> List[float]: + """Port of Vector_UnPackFrameBlockAndDecompressOneFrame's bit decoder. + + Two independent little-endian bit streams: + Stream A (selector): 32-bit reg from pComp[1..4]; refill 24 bits from + pComp[5+1 ..] (selRefillPtr starts at pComp+5, reads [1..3] then +=3) + when count <= 8 after each bone. + Stream B (magnitude): 64-bit lazy buffer; starts at + pComp + 1 + ceil(pComp[0]/8); refill while count <= 56. + ctrl words: big-endian u32 at ctrl_off, one per bone, nibble k = bits + [k*4 .. k*4+4). nb==0 -> 0. Else 1 selector bit; if 1: 1 sign bit + then nb magnitude bits; value = float(mag), negative when sign bit == 0. + Returns flat list of bone_count*8 floats (the IEEE values, sign applied).""" + sel_buf = (data[p_comp + 1] + | (data[p_comp + 2] << 8) + | (data[p_comp + 3] << 16) + | (data[p_comp + 4] << 24)) + sel_cnt = 32 + sel_ref = p_comp + 5 + + sel_hdr_bits = data[p_comp + 0] + mag_ptr = p_comp + 1 + ((sel_hdr_bits + 7) >> 3) + mag_buf = 0 + mag_cnt = 0 + + out: List[float] = [0.0] * (bone_count * 8) + cp = ctrl_off + for bone in range(bone_count): + ctrl = ((data[cp] << 24) | (data[cp + 1] << 16) + | (data[cp + 2] << 8) | data[cp + 3]) + cp += 4 + b8 = bone * 8 + for k in range(8): + nb = (ctrl >> (k * 4)) & 0xF + if nb == 0: + continue + sel = sel_buf & 1 + sel_buf >>= 1 + sel_cnt -= 1 + if sel == 1: + while mag_cnt <= 56: + mag_buf |= data[mag_ptr] << mag_cnt + mag_ptr += 1 + mag_cnt += 8 + sign = mag_buf & 1 + mag_buf >>= 1 + mag_cnt -= 1 + mag = mag_buf & ((1 << nb) - 1) + mag_buf >>= nb + mag_cnt -= nb + fval = float(mag) + if sign == 0: + fval = -fval + out[b8 + k] = fval + if sel_cnt <= 8: + # Engine reads selPtr[0..2] (Vector_UnPackFrameBlockAndDecompress- + # OneFrame @0x82c1e0f8). Reading [1..3] here skipped a byte per + # refill and desynced the selector stream after the first 24 bits. + chunk = (data[sel_ref + 0] + | (data[sel_ref + 1] << 8) + | (data[sel_ref + 2] << 16)) + sel_ref += 3 + sel_buf = (sel_buf | (chunk << sel_cnt)) & 0xFFFFFFFF + sel_cnt += 24 + return out + + +class VBRPartDecoder: + """Decodes one VBR part. decode_frame(f) returns List[SQT] (one per local + bone in this part), reproducing AnimTest's DecompressFrame: + Vector_UnPack (bit decode) -> DecompressFrameBlock (IDCT) -> PutData + distribution via the constant-channel map.""" + + def __init__(self, data: bytes, part: AnimPart): + self.data = data + self.part = part + self.info: Optional[VBRPartInfo] = None + self._block_cache: Dict[int, List[float]] = {} # blockIdx -> scratch + + def _ensure(self) -> VBRPartInfo: + if self.info is None: + self.info = parse_vbr_part(self.data, self.part) + return self.info + + def _scratch_for_block(self, info: VBRPartInfo, block_idx: int) -> List[float]: + cached = self._block_cache.get(block_idx) + if cached is not None: + return cached + data = self.data + # perFrameStream = compData + memHeaderSize + sum(frameBlockSizes[ int: + # perFrameStream[ch] = quant-table channelIndex (read by + # DecompressFrameBlock as compressed[ch]). + stream = info.comp_data_off + info.mem_hdr_size + for b in range(block_idx): + if b < len(info.frame_block_sz): + stream += info.frame_block_sz[b] + return self.data[stream + ch] + + def decode_frame(self, frame_idx: int) -> List[SQT]: + info = self._ensure() + nframes = max(1, info.num_frames) + if frame_idx >= nframes: + frame_idx = nframes - 1 + block_idx = frame_idx >> 3 + frame_in_blk = frame_idx & 7 + + # ---- DecompressFrameBlock IDCT -> per-channel animated values ---- + # normalized[e] for e in [start,end) per channel; e indexes scratch. + frame_off_aln = (info.frame_offset + 3) & ~3 + normalized = [0.0] * (frame_off_aln if frame_off_aln else 1) + + if info.frame_offset > 0 and info.frame_block_sz: + scratch = self._scratch_for_block(info, block_idx) + scale = max(abs(info.dct_min), abs(info.dct_max)) + scale_sq = scale * scale + quant_bias = scale * (1.0 / 32768.0) # rexglue runtime bias + idct_row = _IDCT_COEF_TABLE[frame_in_blk & 7] + for ch, c in enumerate(info.channels): + start = info.block_starts[ch] + end = info.block_ends[ch] + if start == end: + continue + ch_idx = self._channel_index(info, block_idx, ch) & 0xFF + init = _INITIAL_QUANT_TABLES[ch_idx] + quant = [init[i] * scale_sq + quant_bias for i in range(8)] + coef = [quant[i] * idct_row[i] for i in range(8)] + ch_min = c.ch_min + ch_rng = c.ch_max - c.ch_min + for e in range(start, end): + base = e * 8 + s = 0.0 + for k in range(8): + s += scratch[base + k] * coef[k] + normalized[e] = (s + 0.5) * ch_rng + ch_min + + # ---- DecompressFrame distribution (PutData) ---- + # primary (anim) / fallback (const) / flag cursors advance GLOBALLY + # across channels, exactly like the engine. + chans = info.channels + bone_count = 0 + for c in chans: + bone_count = max(bone_count, c.num_ent + c.num_const) + + out = [SQT() for _ in range(bone_count)] + anim_cur = 0 # float index into `normalized` + const_cur = 0 # float index into info.const_values + flag_cur = 0 # joint index into info.is_const + flags = info.is_const + consts = info.const_values + + for ch, c in enumerate(chans): + total = c.num_ent + c.num_const + if total == 0: + continue + size = c.size + for j in range(total): + is_c = flags[flag_cur] if flag_cur < len(flags) else ( + 1 if j >= c.num_ent else 0) + flag_cur += 1 + if is_c: + vals = consts[const_cur:const_cur + size] + const_cur += size + else: + vals = normalized[anim_cur:anim_cur + size] + anim_cur += size + if len(vals) < size: + vals = list(vals) + [0.0] * (size - len(vals)) + if j < bone_count: + sqt = out[j] + if ch == 0: # scale + out[j] = SQT((vals[0], vals[1], vals[2]), + sqt.quat, sqt.trans) + elif ch == 1: # quaternion (x,y,z,w) + out[j] = SQT(sqt.scale, + (vals[0], vals[1], vals[2], vals[3]), + sqt.trans) + elif ch == 2: # translation + out[j] = SQT(sqt.scale, sqt.quat, + (vals[0], vals[1], vals[2])) + return out + + +# --------------------------------------------------------------------------- +# RAW (RD) codec — port of ExtractPackedN (animtest.cpp), with the fixes: +# * NO `mNbBonesM1 == 0` early-exit (not in the engine) +# * header == 0 -> identity-fill the bone group (engine behaviour) +# * identity quat is (0,0,0,1), not (1,0,0,0) +# --------------------------------------------------------------------------- + +@dataclass +class RawCompressionHeader: + masks: List[int] + frame_size: int + nb_bones_m1: int + compressed_t: bool + compressed_q: bool + + +def _raw_read_hdr(data: bytes, part: AnimPart) -> RawCompressionHeader: + base = part.abs_offset + part.comp_hdr_rel + masks = [_u32(data, base + i * 4) for i in range(6)] + frame_size = _u16(data, base + 24) + nb_bones_m1 = data[base + 26] + compressed_t = data[base + 27] != 0 + compressed_q = data[base + 28] != 0 + return RawCompressionHeader(masks, frame_size, nb_bones_m1, + compressed_t, compressed_q) + + +def _raw_channel_has(hdr: RawCompressionHeader, channel: int, kind: int) -> bool: + word = channel >> 5 + bit = 1 << (channel & 31) + return bool(hdr.masks[kind + word] & bit) + + +def _raw_quat_decompress(qcomp: int) -> Tuple[float, float, float, float]: + pi = math.pi + qa = (qcomp >> 21) & 0x7FF + qb = (qcomp >> 11) & 0x3FF + qc = qcomp & 0x7FF + angle_a = (qa / 2047.0) * 2 * pi - pi + sca = qb / 1023.0 + angle_b = (qc / 2047.0) * 2 * pi - pi + x = math.sin(angle_b) * sca + y = math.cos(angle_b) * sca + z = math.sin(angle_a) + w = math.cos(angle_a) + n = math.sqrt(x*x + y*y + z*z + w*w) + if n < 1e-8: + return (0.0, 0.0, 0.0, 1.0) + return (x/n, y/n, z/n, w/n) + + +def _raw_extract_frame(data: bytes, part: AnimPart, frame_idx: int) -> List[SQT]: + """Port of ExtractPackedN. channelCount comes from the part's mHeader + (((mHeader>>6)&0x3F)+1), NOT the compression header's bone count.""" + hdr = _raw_read_hdr(data, part) + ch_count = ((part.m_header >> 6) & 0x3F) + 1 + base = part.abs_offset + part.comp_data_rel + frame_idx * hdr.frame_size + out: List[SQT] = [] + p = base + for ch in range(ch_count): + scale = (1.0, 1.0, 1.0) + quat = (0.0, 0.0, 0.0, 1.0) + trans = (0.0, 0.0, 0.0) + if _raw_channel_has(hdr, ch, 0): + scale = (_f32(data, p), _f32(data, p + 4), _f32(data, p + 8)) + p += 12 + if _raw_channel_has(hdr, ch, 2): + if hdr.compressed_q: + quat = _raw_quat_decompress(_u32(data, p)); p += 4 + else: + quat = (_f32(data, p), _f32(data, p + 4), + _f32(data, p + 8), _f32(data, p + 12)) + p += 16 + if _raw_channel_has(hdr, ch, 4): + if hdr.compressed_t: + tx = struct.unpack_from(">h", data, p)[0] + ty = struct.unpack_from(">h", data, p + 2)[0] + tz = struct.unpack_from(">h", data, p + 4)[0] + rr = _u16(data, p + 6); p += 8 + inv = float(rr) / 32768.0 + trans = (tx * inv, ty * inv, tz * inv) + else: + trans = (_f32(data, p), _f32(data, p + 4), _f32(data, p + 8)) + p += 12 + out.append(SQT(scale, quat, trans)) + return out + + +# --------------------------------------------------------------------------- +# Clip-level frame decode (dispatch RAW vs VBR; skeleton-positional placement) +# --------------------------------------------------------------------------- + +def _is_vbr(hdr: BlockHeader) -> bool: + return hdr.codec == CODEC_VBR or (hdr.codec & 0x00FFFFFF) == 0x00524256 + + +def _is_raw(hdr: BlockHeader) -> bool: + return hdr.codec == CODEC_RAW or (hdr.codec & 0x0000FFFF) == 0x4452 + + +def _place_part_sqts(result: Dict[int, SQT], sqts: List[SQT], + part: AnimPart, hier: Optional[HierarchyData]) -> None: + """Place a part's decoded bones at their skeleton bone indices. + + VBR/RD parts carry no usable partID (high byte 0 for VBR), so the i-th + part maps positionally to the i-th skeleton part; that part's sqtOffset + is the absolute SQTData bone index its bones start at (engine + Vector_ExtractPackedNVBR: sqtBuff + 48*sqtOffset).""" + if hier and 0 <= part.table_index < len(hier.parts): + base = hier.parts[part.table_index].sqt_offset + else: + base = 0 + for j, sqt in enumerate(sqts): + result[base + j] = sqt + + +def decode_clip_frame(data: bytes, clip: AnimClip, + decoders: Dict[int, VBRPartDecoder], + frame_idx: int, + hier: Optional[HierarchyData] = None) -> Dict[int, SQT]: + """Decode one clip frame -> {absolute_bone_index: SQT}.""" + result: Dict[int, SQT] = {} + vbr = _is_vbr(clip.header) + for part in clip.parts: + sqts: List[SQT] = [] + if part.csize > 0: + if vbr: + dec = decoders.get(part.abs_offset) + if dec is None: + dec = VBRPartDecoder(data, part) + decoders[part.abs_offset] = dec + try: + sqts = dec.decode_frame(frame_idx) + except Exception as e: + print(f"[ABIN] VBR decode err (part 0x{part.abs_offset:X} " + f"frame {frame_idx}): {e}") + sqts = [] + else: + try: + sqts = _raw_extract_frame(data, part, frame_idx) + except Exception as e: + print(f"[ABIN] RAW decode err: {e}") + sqts = [] + _place_part_sqts(result, sqts, part, hier) + return result + + +def decode_pose_frame(data: bytes, pose: Pose, + hier: Optional[HierarchyData] = None) -> Dict[int, SQT]: + """Decode a pose (single-frame, all-constant VBR) -> {bone_index: SQT}.""" + result: Dict[int, SQT] = {} + for part in pose.parts: + if part.csize > 0: + try: + dec = VBRPartDecoder(data, part) + sqts = dec.decode_frame(0) + except Exception as e: + print(f"[ABIN] Pose part decode err at " + f"0x{part.abs_offset:X}: {e}") + sqts = [] + _place_part_sqts(result, sqts, part, hier) + return result + + +def find_pose_by_name(abin: "AbinFile", name: str) -> Optional[Pose]: + """Find the first pose block matching a name (case-insensitive).""" + if not name: + return None + u = name.upper() + for p in abin.poses: + if p.header.name.upper() == u: + return p + return None + + +def find_default_base_pose(abin: "AbinFile") -> Optional[Pose]: + """Pick the best non-TPOSE pose to use as base, or None.""" + priority = ["BOARD_BACKWARDS_IK", "BOARD_BACKWARDS", + "POSTURE_BUFF_POSE", "POSTURE_STIFF_POSE", + "POSTURE_SLOUCH_POSE", "RIG_TPOSE"] + for name in priority: + p = find_pose_by_name(abin, name) + if p is not None: + return p + return abin.poses[0] if abin.poses else None + + +# --------------------------------------------------------------------------- +# Channel → bone name lookup +# --------------------------------------------------------------------------- + +def build_channel_bone_name_map(hier: HierarchyData, + bone_names: List[str]) -> Dict[int, str]: + """Decoded frames are now keyed by ABSOLUTE skeleton bone index (the + engine's SQTData index, via each part's skeleton sqtOffset). The skeleton + is in canonical BONE_NAMES order, so the map is the identity index->name, + bounded by the .abin's actual bone count.""" + mapping: Dict[int, str] = {} + n = min(hier.num_bones, len(bone_names)) + for i in range(n): + mapping[i] = bone_names[i] + return mapping + + +# --------------------------------------------------------------------------- +# Blender application +# --------------------------------------------------------------------------- + +if bpy is not None: + import mathutils + + # Quaternion axis-convention table. EA ANT stores quats as (x,y,z,w). + # Blender mathutils.Quaternion takes (w, x, y, z). + # Depending on how rw4 skel importer built bones (bone-length axis, + # bone roll), the EA ANT → Blender remap differs. + QUAT_CONVENTIONS = [ + ("DIRECT", "Direct (no swap)", lambda q: (q[3], q[0], q[1], q[2])), + ("XY_SWAP", "Swap X<->Y, negate Y", lambda q: (q[3], -q[1], q[0], q[2])), + ("XY_SWAP2", "Swap X<->Y, negate X", lambda q: (q[3], q[1], -q[0], q[2])), + ("NEG_Y", "Negate Y", lambda q: (q[3], q[0], -q[1], q[2])), + ("NEG_X", "Negate X", lambda q: (q[3], -q[0], q[1], q[2])), + ("NEG_Z", "Negate Z", lambda q: (q[3], q[0], q[1], -q[2])), + ("YZ_SWAP", "Swap Y<->Z", lambda q: (q[3], q[0], q[2], q[1])), + ("XZ_SWAP", "Swap X<->Z", lambda q: (q[3], q[2], q[1], q[0])), + ] + QUAT_ITEMS = [(k, desc, desc) for k, desc, _ in QUAT_CONVENTIONS] + QUAT_FUNCS = {k: f for k, _, f in QUAT_CONVENTIONS} + + TRANS_CONVENTIONS = [ + ("DIRECT", "Direct (no swap)", lambda t: (t[0], t[1], t[2])), + ("XY_SWAP", "Swap X<->Y, neg Y", lambda t: (-t[1], t[0], t[2])), + ("XZ_SWAP", "Swap X<->Z", lambda t: (t[2], t[1], t[0])), + ("NEG_Y", "Negate Y", lambda t: (t[0], -t[1], t[2])), + ("NEG_Z", "Negate Z", lambda t: (t[0], t[1], -t[2])), + ("YZ_SWAP", "Swap Y<->Z", lambda t: (t[0], t[2], t[1])), + ] + TRANS_ITEMS = [(k, desc, desc) for k, desc, _ in TRANS_CONVENTIONS] + TRANS_FUNCS = {k: f for k, _, f in TRANS_CONVENTIONS} + + # Bone name substrings that identify "finger/face" bones that may + # need a different quat convention than body bones. + _FINGER_TOKENS = ( + "HAND", "THUMB", "INDEX", "MIDDLE", "RING", "PINKY", + "FINGER", "FACE", "JAW", "LIP", "EYE", "MOUTH", "TONGUE", + "BROW", "CHEEK", "NOSE", "CHIN", "CREASE", "LID", + ) + + def _is_finger_bone(name: str) -> bool: + u = name.upper() + return any(tok in u for tok in _FINGER_TOKENS) + + def _apply_clip_to_armature( + arm_obj, data: bytes, clip: AnimClip, hier: HierarchyData, + action_name: str, quat_conv: str = "XZ_SWAP", trans_conv: str = "XZ_SWAP", + finger_quat_conv: str = "XZ_SWAP", + push_to_nla: bool = True, + base_pose_data: Optional[Dict[int, SQT]] = None, + compose_base: bool = False, + ) -> None: + if arm_obj.animation_data is None: + arm_obj.animation_data_create() + + action = bpy.data.actions.new(name=action_name) + arm_obj.animation_data.action = action + + pb_map = {pb.name.upper(): pb for pb in arm_obj.pose.bones} + ch_to_name = build_channel_bone_name_map(hier, BONE_NAMES) + ch_to_pb = {ch: pb_map[name.upper()] + for ch, name in ch_to_name.items() + if name.upper() in pb_map} + + print(f"[ABIN] '{action_name}': {clip.num_frames} frames, " + f"{len(ch_to_pb)}/{len(ch_to_name)} bones matched") + + qfn_body = QUAT_FUNCS[quat_conv] + qfn_finger = QUAT_FUNCS[finger_quat_conv] + tfn = TRANS_FUNCS[trans_conv] + + ch_qfn: Dict[int, callable] = {} + for ch, name in ch_to_name.items(): + ch_qfn[ch] = qfn_finger if _is_finger_bone(name) else qfn_body + + base_q: Dict[int, "mathutils.Quaternion"] = {} + base_t: Dict[int, "mathutils.Vector"] = {} + if compose_base and base_pose_data: + for ch, sqt in base_pose_data.items(): + qfn = ch_qfn.get(ch, qfn_body) + bw, bx, by, bz = qfn(sqt.quat) + base_q[ch] = mathutils.Quaternion((bw, bx, by, bz)) + base_t[ch] = mathutils.Vector(tfn(sqt.trans)) + + for pb in ch_to_pb.values(): + pb.rotation_mode = "QUATERNION" + + vbr_decoders: Dict[int, VBRPartDecoder] = {} + # Previous-frame quaternion per channel, for sign continuity. + # Blender linearly interpolates keyframes — if adjacent quats have + # dot(q_prev, q_curr) < 0, interpolation passes through origin and + # the skeleton explodes. Negate q_curr when that happens. + prev_q: Dict[int, "mathutils.Quaternion"] = {} + + for fi in range(clip.num_frames): + frame_data = decode_clip_frame(data, clip, vbr_decoders, fi, hier) + + for ch, sqt in frame_data.items(): + pb = ch_to_pb.get(ch) + if pb is None: + continue + qfn = ch_qfn.get(ch, qfn_body) + qw, qx, qy, qz = qfn(sqt.quat) + q = mathutils.Quaternion((qw, qx, qy, qz)) + t = mathutils.Vector(tfn(sqt.trans)) + + if compose_base and ch in base_q: + q = base_q[ch] @ q + t = base_t[ch] + t + + # Sign continuity: keep dot(prev, curr) >= 0 + p = prev_q.get(ch) + if p is not None: + if p.w * q.w + p.x * q.x + p.y * q.y + p.z * q.z < 0.0: + q = mathutils.Quaternion((-q.w, -q.x, -q.y, -q.z)) + prev_q[ch] = q + + pb.rotation_quaternion = q + pb.location = t + pb.scale = sqt.scale + + pb.keyframe_insert("location", frame=fi + 1) + pb.keyframe_insert("rotation_quaternion", frame=fi + 1) + pb.keyframe_insert("scale", frame=fi + 1) + + if push_to_nla: + track = arm_obj.animation_data.nla_tracks.new() + track.name = action_name + track.strips.new(action_name, 1, action) + arm_obj.animation_data.action = None + + print(f"[ABIN] '{action_name}' done.") + + + def _apply_pose_to_armature( + arm_obj, data: bytes, pose: Pose, hier: HierarchyData, + quat_conv: str = "XY_SWAP", trans_conv: str = "XY_SWAP", + ) -> None: + pb_map = {pb.name.upper(): pb for pb in arm_obj.pose.bones} + ch_to_name = build_channel_bone_name_map(hier, BONE_NAMES) + ch_to_pb = {ch: pb_map[name.upper()] + for ch, name in ch_to_name.items() + if name.upper() in pb_map} + + qfn = QUAT_FUNCS[quat_conv] + tfn = TRANS_FUNCS[trans_conv] + + pose_sqts = decode_pose_frame(data, pose, hier) + for ch, sqt in pose_sqts.items(): + pb = ch_to_pb.get(ch) + if pb is None: + continue + pb.rotation_mode = "QUATERNION" + qw, qx, qy, qz = qfn(sqt.quat) + pb.rotation_quaternion = mathutils.Quaternion((qw, qx, qy, qz)) + pb.location = mathutils.Vector(tfn(sqt.trans)) + pb.scale = sqt.scale + print(f"[ABIN] Applied pose '{pose.header.name}'") + + + class IMPORT_OT_skate3_abin(bpy.types.Operator): + bl_idname = "import_anim.skate3_abin" + bl_label = "Import Skate 3 Animation (.abin)" + bl_options = {"REGISTER", "UNDO"} + + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filter_glob: bpy.props.StringProperty(default="*.abin", options={"HIDDEN"}) + + clip_index: bpy.props.IntProperty( + name="Clip Index", + description="Clip to import (-1 = all, 0 = first, N = Nth)", + default=-1, min=-1, + ) + max_clips: bpy.props.IntProperty( + name="Max Clips (when all)", + description="Cap total clips when clip_index=-1 (0 = no cap). " + "Some .abin have thousands of clips; capping " + "prevents multi-hour imports. Default 50.", + default=50, min=0, + ) + debug: bpy.props.BoolProperty(name="Print Debug", default=True) + world_up_correction: bpy.props.BoolProperty( + name="Fix Y-up to Z-up (rotate armature +90° X)", + default=False, + ) + apply_base_pose: bpy.props.BoolProperty( + name="Apply Base Pose", + description="Set the selected pose as the character's base stance " + "(what the game uses before animations play).", + default=True, + ) + base_pose_name: bpy.props.StringProperty( + name="Base Pose Name", + description="Name of pose to use as base. Common values: " + "RIG_TPOSE, BOARD_BACKWARDS, BOARD_BACKWARDS_IK, " + "POSTURE_BUFF_POSE. Leave blank for first non-TPOSE.", + default="BOARD_BACKWARDS_IK", + ) + compose_base_with_anim: bpy.props.BoolProperty( + name="Compose Base Onto Animation", + description="Multiply each animation frame's rotation by the " + "base pose rotation. Needed if animations are stored " + "as deltas from the base pose.", + default=False, + ) + push_to_nla: bpy.props.BoolProperty( + name="Push Each Clip to NLA", + description="Each clip becomes an NLA strip so multi-clip " + "imports don't overwrite the active action.", + default=True, + ) + quat_conv: bpy.props.EnumProperty( + name="Quat Axis Convention", + description="Axis swap for body bones. " + "XZ_SWAP confirmed correct for skate 3 Skel.blend.", + items=QUAT_ITEMS, default="XZ_SWAP", + ) + trans_conv: bpy.props.EnumProperty( + name="Trans Axis Convention", + description="Axis swap for translations.", + items=TRANS_ITEMS, default="XZ_SWAP", + ) + finger_quat_conv: bpy.props.EnumProperty( + name="Finger Quat Convention", + description="Axis swap for hand/finger/face bones. Fingers " + "have different bind-pose roll from body bones, so " + "they often need a different swap than body.", + items=QUAT_ITEMS, default="XZ_SWAP", + ) + + def execute(self, context): + arm_obj = context.object + if arm_obj is None or arm_obj.type != "ARMATURE": + self.report({"ERROR"}, "Select an armature first.") + return {"CANCELLED"} + try: + data = open(self.filepath, "rb").read() + except OSError as e: + self.report({"ERROR"}, f"Read failed: {e}") + return {"CANCELLED"} + + abin = AbinFile(data) + + if self.debug: + print(f"[ABIN] File: {self.filepath}") + print(f"[ABIN] Clips: {len(abin.clips)} Poses: {len(abin.poses)} " + f"Hier: {'yes' if abin.hierarchy else 'no'}") + if abin.hierarchy: + h = abin.hierarchy + print(f"[ABIN] Hier: {h.num_bones} bones, " + f"has_traj={h.has_traj}, parts={h.num_parts}") + for i, c in enumerate(abin.clips[:10]): + active = sum(1 for p in c.parts if p.csize > 0) + codec = "VBR" if _is_vbr(c.header) else ("RAW" if _is_raw(c.header) else "???") + print(f"[ABIN] Clip[{i}]: '{c.header.name}' {codec} " + f"fps={c.fps} frames={c.num_frames} parts={len(c.parts)} active={active}") + + if not abin.clips: + self.report({"ERROR"}, "No animation clips found.") + return {"CANCELLED"} + if abin.hierarchy is None: + self.report({"ERROR"}, "No hierarchy block.") + return {"CANCELLED"} + + # World-up rotation applied ONCE to armature object (not per clip). + if self.world_up_correction: + arm_obj.rotation_mode = "XYZ" + arm_obj.rotation_euler = (math.pi / 2, 0.0, 0.0) + + # Select base pose. Prefer user-named pose, fall back to + # first non-TPOSE, final fallback to first pose. + base_pose: Optional[Pose] = None + if self.apply_base_pose and abin.poses: + if self.base_pose_name.strip(): + base_pose = find_pose_by_name(abin, self.base_pose_name) + if base_pose is None and self.debug: + print(f"[ABIN] Pose '{self.base_pose_name}' not found, " + f"available: {sorted({p.header.name for p in abin.poses})}") + if base_pose is None: + base_pose = find_default_base_pose(abin) + if base_pose is not None: + print(f"[ABIN] Using base pose: '{base_pose.header.name}'") + try: + _apply_pose_to_armature( + arm_obj, data, base_pose, abin.hierarchy, + quat_conv=self.quat_conv, trans_conv=self.trans_conv, + ) + except Exception as e: + if self.debug: + print(f"[ABIN] Pose apply failed: {e}") + + # Decode base pose once for composition use + base_pose_data: Optional[Dict[int, SQT]] = None + if self.compose_base_with_anim and base_pose is not None: + try: + base_pose_data = decode_pose_frame(data, base_pose, abin.hierarchy) + except Exception as e: + if self.debug: + print(f"[ABIN] Base pose decode err: {e}") + + if self.clip_index == -1: + clips_to_import = abin.clips + if self.max_clips > 0: + clips_to_import = clips_to_import[:self.max_clips] + else: + idx = max(0, min(self.clip_index, len(abin.clips) - 1)) + clips_to_import = [abin.clips[idx]] + + # Use clip[0]'s fps/frames for scene settings. + if clips_to_import: + c0 = clips_to_import[0] + scene = bpy.context.scene + scene.render.fps = max(1, int(round(c0.fps))) + scene.frame_start = 1 + scene.frame_end = max(c0.num_frames, scene.frame_end) + + imported = 0 + for idx, clip in enumerate(clips_to_import): + if not (_is_vbr(clip.header) or _is_raw(clip.header)): + continue + name = clip.header.name or f"abin_clip_{idx}" + try: + _apply_clip_to_armature( + arm_obj, data, clip, abin.hierarchy, + action_name=name, + quat_conv=self.quat_conv, + trans_conv=self.trans_conv, + finger_quat_conv=self.finger_quat_conv, + push_to_nla=self.push_to_nla and len(clips_to_import) > 1, + base_pose_data=base_pose_data, + compose_base=self.compose_base_with_anim, + ) + imported += 1 + except Exception as e: + print(f"[ABIN] Failed to import '{name}': {e}") + + self.report({"INFO"}, f"Imported {imported}/{len(clips_to_import)} clip(s).") + return {"FINISHED"} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + + def _menu_func(self, context): + self.layout.operator( + IMPORT_OT_skate3_abin.bl_idname, + text="Skate 3 Animation (.abin)", + ) + + + def register(): + bpy.utils.register_class(IMPORT_OT_skate3_abin) + bpy.types.TOPBAR_MT_file_import.append(_menu_func) + + + def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(_menu_func) + bpy.utils.unregister_class(IMPORT_OT_skate3_abin) + + + if __name__ == "__main__": + register() + + +# --------------------------------------------------------------------------- +# Standalone test harness (run outside Blender) +# --------------------------------------------------------------------------- + +else: + def _standalone_test(filepath: str) -> None: + data = open(filepath, "rb").read() + abin = AbinFile(data) + print(f"File: {filepath} ({len(data):,} bytes)") + if abin.hierarchy: + h = abin.hierarchy + print(f"Hierarchy: {h.num_bones} bones has_traj={h.has_traj} parts={h.num_parts}") + print(f"Clips: {len(abin.clips)} Poses: {len(abin.poses)}") + for i, clip in enumerate(abin.clips[:5]): + codec = "VBR" if _is_vbr(clip.header) else ("RAW" if _is_raw(clip.header) else "???") + print(f"\nClip[{i}] '{clip.header.name}' {codec} fps={clip.fps} frames={clip.num_frames} parts={len(clip.parts)}") + for pi, part in enumerate(clip.parts): + print(f" Part[{pi}] mHdr=0x{part.m_header:08X} cds=0x{part.csize:X} " + f"hdr=0x{part.comp_hdr_rel:X} data=0x{part.comp_data_rel:X} ch={part.channel_count}") + if _is_vbr(clip.header) and part.csize > 0: + try: + info = parse_vbr_part(data, part) + total_ch = sum(c.num_ent + c.num_const + for c in info.channels) + print(f" VBR: NF={info.num_frames} " + f"Dct=[{info.dct_min:.3f},{info.dct_max:.3f}] " + f"nch={info.num_channels} pal={info.palette_size} " + f"FO={info.frame_offset} total_ch={total_ch}") + for bi, b in enumerate(info.channels): + print(f" blk[{bi}]: ent={b.num_ent} const={b.num_const} " + f"range=[{b.ch_min:.3f},{b.ch_max:.3f}] size={b.size}") + print(f" FBS: {info.frame_block_sz} (sum={sum(info.frame_block_sz)})") + print(f" mem_hdr={info.mem_hdr_size} bytes " + f"(const+chanmap={info.num_bits_off}, " + f"nibbles={4*info.frame_offset})") + except Exception as e: + print(f" VBR parse err: {e}") + # Decode a few frames + if clip.num_frames > 0 and _is_vbr(clip.header): + decoders: Dict[int, VBRPartDecoder] = {} + for fi in [0, clip.num_frames // 2, clip.num_frames - 1]: + try: + frame = decode_clip_frame(data, clip, decoders, fi, abin.hierarchy) + print(f" Frame {fi}: {len(frame)} channels") + for ch in list(frame.keys())[:3]: + s = frame[ch] + print(f" ch {ch}: s=({s.scale[0]:.3f},{s.scale[1]:.3f},{s.scale[2]:.3f}) " + f"q=({s.quat[0]:.3f},{s.quat[1]:.3f},{s.quat[2]:.3f},{s.quat[3]:.3f}) " + f"t=({s.trans[0]:.3f},{s.trans[1]:.3f},{s.trans[2]:.3f})") + except Exception as e: + print(f" Frame {fi} err: {e}") + + if __name__ == "__main__": + import sys + if len(sys.argv) < 2: + print("Usage: python abin_importer.py ") + sys.exit(1) + _standalone_test(sys.argv[1]) diff --git a/tools/vendor/skate3_anim/blender_rx2_abin_export.py b/tools/vendor/skate3_anim/blender_rx2_abin_export.py new file mode 100644 index 00000000..9174f75e --- /dev/null +++ b/tools/vendor/skate3_anim/blender_rx2_abin_export.py @@ -0,0 +1,1001 @@ +"""Export one Skate 3 ABIN clip to Blender using the viewer's RX2 rig. + +Run with Blender: + blender --background --python blender_rx2_abin_export.py -- \ + --abin anim/OnBoard.abin --rx2 ai_skater_01 \ + --clip KICKFLIP_IN_LOW_G --output out/Kickflip.blend +""" + +from __future__ import annotations + +import argparse +import glob +import json +import math +import os +from pathlib import Path +import sys + +import bpy +from mathutils import Matrix, Vector +import numpy as np + + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +import abin_importer as ABIN +import rx2_skeleton as RX2 + + +SOURCE_TO_BLENDER = Matrix( + ( + (1.0, 0.0, 0.0, 0.0), + (0.0, 0.0, -1.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + ) +) +BLENDER_TO_SOURCE = SOURCE_TO_BLENDER.inverted() + + +def quat_matrix(quaternion, scale=(1.0, 1.0, 1.0)): + """Viewer-equivalent row-vector SQT rotation matrix.""" + x, y, z, w = quaternion + norm = x * x + y * y + z * z + w * w + if norm < 1e-12: + x = y = z = 0.0 + w = 1.0 + else: + inverse_norm = 1.0 / math.sqrt(norm) + x, y, z, w = ( + x * inverse_norm, + y * inverse_norm, + z * inverse_norm, + w * inverse_norm, + ) + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + matrix = np.identity(4, dtype=np.float64) + matrix[0, 0] = 1 - 2 * (yy + zz) + matrix[0, 1] = 2 * (xy + wz) + matrix[0, 2] = 2 * (xz - wy) + matrix[1, 0] = 2 * (xy - wz) + matrix[1, 1] = 1 - 2 * (xx + zz) + matrix[1, 2] = 2 * (yz + wx) + matrix[2, 0] = 2 * (xz + wy) + matrix[2, 1] = 2 * (yz - wx) + matrix[2, 2] = 1 - 2 * (xx + yy) + sx, sy, sz = scale + matrix[0, :3] *= sx if abs(sx) >= 1e-9 else 1.0 + matrix[1, :3] *= sy if abs(sy) >= 1e-9 else 1.0 + matrix[2, :3] *= sz if abs(sz) >= 1e-9 else 1.0 + return matrix + + +def rigid_inverse(matrix): + result = np.identity(4, dtype=np.float64) + rotation = matrix[:3, :3] + result[:3, :3] = rotation.T + result[3, :3] = -(matrix[3, :3] @ rotation.T) + return result + + +class AnimSource: + def __init__(self, path): + self.path = path + self.data = Path(path).read_bytes() + self.abin = ABIN.AbinFile(self.data) + self.hier = self.abin.hierarchy + self.parents = list(self.hier.parents) if self.hier else [] + self.clips = list(self.abin.clips) + self.poses = list(self.abin.poses) + self._decoders = {} + self._cache = {} + self._cache_key = None + self.ref_pose = self._load_ref_pose() + + def _load_ref_pose(self): + for pose in self.poses: + if pose.header.name == "RIG_TPOSE": + return ABIN.decode_pose_frame( + self.data, pose, self.hier + ) + return {} + + def frame(self, clip_index, frame_index): + if self._cache_key != clip_index: + self._cache_key = clip_index + self._cache = {} + self._decoders = {} + if frame_index not in self._cache: + self._cache[frame_index] = ABIN.decode_clip_frame( + self.data, + self.clips[clip_index], + self._decoders, + frame_index, + self.hier, + ) + return self._cache[frame_index] + + +class SkeletonSet: + """Blender-safe copy of SkateAnimViewer's RX2-to-ABIN mapper.""" + + def __init__(self, folder): + self.models = [] + self.bind = {} + self.ibp = {} + self.errors = [] + files = sorted( + glob.glob(os.path.join(folder, "**", "*.rx2"), recursive=True) + ) + for path in files: + try: + parsed = RX2.parse_rx2(path) + except Exception as error: + self.errors.append(f"{Path(path).name}: {error}") + continue + bones = parsed.get("bones") or [] + meshes = parsed.get("meshes") or [] + self.models.append( + (Path(path).parent.name, Path(path).name, bones, meshes) + ) + for bone in bones: + name = (bone.get("name") or "").upper() + if not name: + continue + if name not in self.bind and bone.get("bind_matrix") is not None: + self.bind[name] = np.asarray( + bone["bind_matrix"], dtype=np.float64 + ).reshape(4, 4) + if name not in self.ibp and bone.get("ibp_matrix") is not None: + self.ibp[name] = np.asarray( + bone["ibp_matrix"], dtype=np.float64 + ).reshape(4, 4) + + def _rx2_parent_names(self): + if getattr(self, "_parent_names", None) is not None: + return self._parent_names + parent_names = {} + for _, _, bones, _ in self.models: + by_index = {bone.get("index", -1): bone for bone in bones} + for bone in bones: + name = (bone.get("name") or "").upper() + if not name or name in parent_names: + continue + parent = by_index.get(bone.get("parent", -1)) + parent_names[name] = ( + (parent.get("name") or "").upper() if parent else None + ) + self._parent_names = parent_names + return parent_names + + def index_map(self, names, parents=None): + upper = [name.upper() for name in names] + result = {} + self.rejected = [] + if parents is None: + for index, name in enumerate(upper): + result.setdefault(name, index) + return result + known = set(upper[: len(parents)]) + rx2_parents = self._rx2_parent_names() + for index in range(min(len(parents), len(upper))): + name = upper[index] + if not name or name in result: + continue + rx2_parent = rx2_parents.get(name) + if rx2_parent is not None and rx2_parent in known: + parent = parents[index] + expected = upper[parent] if 0 <= parent < len(upper) else None + if expected != rx2_parent: + self.rejected.append( + (index, name, expected, rx2_parent) + ) + continue + result[name] = index + return result + + def local_bind_matrices(self, parents, names): + mapping = self.index_map(names, parents) + world = { + index: matrix + for name, matrix in self.bind.items() + if (index := mapping.get(name)) is not None + and index < len(parents) + } + return { + index: ( + matrix @ rigid_inverse(world[parent]) + if (parent := parents[index]) in world + else matrix.copy() + ) + for index, matrix in world.items() + } + + def renderable_meshes(self, names, bone_count, parents=None): + mapping = self.index_map(names, parents) + output = [] + for folder, filename, bones, meshes in self.models: + bones_by_index = { + bone.get("index", -1): bone for bone in bones + } + resolved = {} + + def resolve(bone_index): + if bone_index in resolved: + return resolved[bone_index] + current = bone_index + seen = set() + answer = None + while ( + current is not None + and current >= 0 + and current not in seen + ): + seen.add(current) + bone = bones_by_index.get(current) + if bone is None: + break + candidate = mapping.get( + (bone.get("name") or "").upper() + ) + if candidate is not None and candidate < bone_count: + answer = candidate + break + current = bone.get("parent", -1) + resolved[bone_index] = answer + return answer + + local_to_abin = { + bone.get("index", -1): answer + for bone in bones + if (answer := resolve(bone.get("index", -1))) is not None + } + for mesh in meshes: + positions = mesh.get("positions") + indices = mesh.get("indices") + if not positions or not indices: + continue + triangles = [ + tuple(indices[index : index + 3]) + for index in range(0, len(indices) - 2, 3) + ] + triangles = [ + triangle + for triangle in triangles + if max(triangle) < len(positions) + ] + raw_indices = mesh.get("skin_indices") + raw_weights = mesh.get("skin_weights") + used = mesh.get("used_bone_indices") or [] + skin = None + if ( + raw_indices + and raw_weights + and len(raw_indices) >= len(positions) + and len(raw_weights) >= len(positions) + ): + skin = [] + for vertex_index in range(len(positions)): + influences = [] + for palette_index, weight in zip( + raw_indices[vertex_index], + raw_weights[vertex_index], + ): + if weight <= 1e-4: + continue + palette_index = int(palette_index) + model_bone = ( + used[palette_index] + if palette_index < len(used) + else palette_index + ) + abin_bone = local_to_abin.get(int(model_bone)) + if abin_bone is not None: + influences.append( + (abin_bone, float(weight)) + ) + skin.append(influences) + fallback = next( + ( + local_to_abin[bone] + for bone in used + if bone in local_to_abin + ), + None, + ) + if fallback is None: + fallback = self._nearest_bone( + positions, names, bone_count, parents + ) + if skin is not None and fallback is not None: + skin = [ + influences or [(fallback, 1.0)] + for influences in skin + ] + output.append( + { + "name": filename, + "folder": folder, + "pos": positions, + "tris": triangles, + "skin": skin, + } + ) + best = {} + for mesh in output: + key = mesh["folder"] + if key not in best or len(mesh["pos"]) > len(best[key]["pos"]): + best[key] = mesh + return [best[key] for key in sorted(best)] + + def _nearest_bone(self, positions, names, bone_count, parents=None): + mapping = self.index_map(names, parents) + candidates = [ + (index, matrix[3, :3]) + for name, matrix in self.bind.items() + if (index := mapping.get(name)) is not None + and index < bone_count + ] + if not candidates: + return None + centroid = np.asarray(positions, dtype=np.float64).mean(axis=0) + return min( + candidates, + key=lambda item: float(np.linalg.norm(item[1] - centroid)), + )[0] + + +def parse_args() -> argparse.Namespace: + argv = sys.argv + argv = argv[argv.index("--") + 1 :] if "--" in argv else [] + parser = argparse.ArgumentParser() + parser.add_argument("--abin", required=True) + parser.add_argument( + "--reference-abin", + help=( + "Optional ABIN supplying RIG_TPOSE when --abin is a minimal " + "clip-only bank. Its hierarchy must match --abin." + ), + ) + parser.add_argument( + "--extra-abin", + help=( + "Optional second ABIN with the same hierarchy. Clips requested " + "with --extra-clip are appended to the same armature." + ), + ) + parser.add_argument("--rx2", required=True) + parser.add_argument( + "--clip", + required=True, + action="append", + help="Exact clip name; repeat to append clips sequentially in NLA.", + ) + parser.add_argument( + "--extra-clip", + action="append", + default=[], + help=( + "Exact clip name from --extra-abin; repeat to append multiple " + "cross-bank actions." + ), + ) + parser.add_argument("--output", required=True) + parser.add_argument( + "--preview-fps", + type=int, + default=0, + help="Scene FPS override; 0 preserves the native clip FPS.", + ) + return parser.parse_args(argv) + + +def row_source_to_blender(matrix) -> Matrix: + """Convert a Y-up row-vector source matrix to Z-up Blender columns.""" + source_column = Matrix(np.asarray(matrix, dtype=np.float64).T.tolist()) + return SOURCE_TO_BLENDER @ source_column @ BLENDER_TO_SOURCE + + +def point_source_to_blender(point) -> Vector: + return Vector((float(point[0]), -float(point[2]), float(point[1]))) + + +def clear_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in ( + bpy.data.meshes, + bpy.data.armatures, + bpy.data.materials, + bpy.data.actions, + ): + for datablock in list(collection): + collection.remove(datablock) + + +def compute_world_pose(source, skeleton, clip_index: int, frame_index: int): + """Mirror SkateAnimViewer.Viewer._pose through world-matrix creation.""" + frame = source.frame(clip_index, frame_index) + parents = source.parents + ref = source.ref_pose + bind = skeleton.local_bind_matrices(parents, ABIN.BONE_NAMES) + matrices = {} + for bone in range(len(parents)): + sqt = frame.get(bone) + bind_local = bind.get(bone) + if sqt is None and bind_local is None: + continue + if sqt is None: + local = bind_local.copy() + else: + local = quat_matrix(sqt.quat, sqt.scale) + local[3, :3] = np.asarray(sqt.trans, dtype=np.float64) + bind_translation = ( + bind_local[3, :3] + if bind_local is not None + else np.zeros(3, dtype=np.float64) + ) + reference = ref.get(bone) + if reference is not None: + reference_matrix = quat_matrix( + reference.quat, reference.scale + ) + reference_matrix[3, :3] = bind_translation + local = local @ reference_matrix + else: + local[3, :3] += bind_translation + parent = parents[bone] + matrices[bone] = ( + local @ matrices[parent] if parent in matrices else local + ) + return matrices + + +def mapped_bind_world(skeleton, parents): + name_to_index = skeleton.index_map(ABIN.BONE_NAMES, parents) + result = {} + for name, matrix in skeleton.bind.items(): + index = name_to_index.get(name) + if index is not None and index < len(parents): + result[index] = matrix + return result + + +def bone_lengths(bind_world, parents): + children = {} + for child, parent in enumerate(parents): + children.setdefault(parent, []).append(child) + result = {} + for index, matrix in bind_world.items(): + origin = np.asarray(matrix[3, :3], dtype=np.float64) + distances = [] + for child in children.get(index, []): + child_matrix = bind_world.get(child) + if child_matrix is None: + continue + distance = float( + np.linalg.norm( + np.asarray(child_matrix[3, :3], dtype=np.float64) + - origin + ) + ) + if distance > 1e-4: + distances.append(distance) + result[index] = min(0.25, max(0.035, min(distances, default=0.08))) + return result + + +def create_armature(bind_world, parents): + armature_data = bpy.data.armatures.new("Skate3_RX2_Rig") + armature = bpy.data.objects.new("Skate3_RX2_Rig", armature_data) + bpy.context.collection.objects.link(armature) + bpy.context.view_layer.objects.active = armature + armature.select_set(True) + armature.show_in_front = True + armature.data.display_type = "STICK" + + lengths = bone_lengths(bind_world, parents) + bpy.ops.object.mode_set(mode="EDIT") + edit_by_index = {} + intended_rest = {} + for index in sorted(bind_world): + matrix = row_source_to_blender(bind_world[index]) + intended_rest[index] = matrix + edit_bone = armature_data.edit_bones.new(ABIN.BONE_NAMES[index]) + head = matrix.translation + rotation = matrix.to_3x3().normalized() + y_axis = (rotation @ Vector((0.0, 1.0, 0.0))).normalized() + z_axis = (rotation @ Vector((0.0, 0.0, 1.0))).normalized() + edit_bone.head = head + edit_bone.tail = head + y_axis * lengths[index] + edit_bone.align_roll(z_axis) + edit_by_index[index] = edit_bone + + for index, edit_bone in edit_by_index.items(): + parent = parents[index] + if parent in edit_by_index: + edit_bone.parent = edit_by_index[parent] + edit_bone.use_connect = False + bpy.ops.object.mode_set(mode="OBJECT") + + rest_error = 0.0 + for index, intended in intended_rest.items(): + actual = armature.data.bones[ABIN.BONE_NAMES[index]].matrix_local + rest_error = max( + rest_error, + max( + abs(actual[row][column] - intended[row][column]) + for row in range(4) + for column in range(4) + ), + ) + return armature, edit_by_index, rest_error + + +def create_material(name: str, color): + material = bpy.data.materials.new(name) + material.diffuse_color = (*color, 1.0) + material.roughness = 0.8 + return material + + +def create_meshes(armature, skeleton, parents): + renderable = skeleton.renderable_meshes( + ABIN.BONE_NAMES, len(parents), parents + ) + palette = ( + (0.20, 0.32, 0.55), + (0.26, 0.42, 0.68), + (0.36, 0.48, 0.64), + (0.24, 0.28, 0.36), + ) + objects = [] + total_vertices = 0 + total_triangles = 0 + for mesh_index, source_mesh in enumerate(renderable): + positions = source_mesh.get("pos") or [] + triangles = source_mesh.get("tris") or [] + if not positions or not triangles: + continue + mesh_data = bpy.data.meshes.new( + f"{source_mesh.get('folder', 'Part')}_{mesh_index}" + ) + mesh_data.from_pydata( + [point_source_to_blender(position) for position in positions], + [], + triangles, + ) + mesh_data.update() + mesh_object = bpy.data.objects.new(mesh_data.name, mesh_data) + bpy.context.collection.objects.link(mesh_object) + mesh_object.data.materials.append( + create_material( + f"{mesh_data.name}_Material", palette[mesh_index % len(palette)] + ) + ) + + groups = {} + skin = source_mesh.get("skin") + if skin: + for vertex_index, influences in enumerate(skin): + combined = {} + for bone_index, weight in influences: + if bone_index >= len(ABIN.BONE_NAMES) or weight <= 1e-5: + continue + combined[bone_index] = ( + combined.get(bone_index, 0.0) + float(weight) + ) + for bone_index, weight in combined.items(): + name = ABIN.BONE_NAMES[bone_index] + group = groups.get(name) + if group is None: + group = mesh_object.vertex_groups.new(name=name) + groups[name] = group + group.add([vertex_index], float(weight), "REPLACE") + + modifier = mesh_object.modifiers.new("Skate3_RX2_Skin", "ARMATURE") + modifier.object = armature + modifier.use_vertex_groups = True + mesh_object.parent = armature + objects.append((mesh_object, source_mesh)) + total_vertices += len(positions) + total_triangles += len(triangles) + return objects, total_vertices, total_triangles + + +def mapped_inverse_bind(skeleton, parents): + mapping = skeleton.index_map(ABIN.BONE_NAMES, parents) + return { + index: matrix + for name, matrix in skeleton.ibp.items() + if (index := mapping.get(name)) is not None + and index < len(parents) + } + + +def validate_deformed_meshes( + mesh_pairs, armature, source, skeleton, baked +): + """Compare Blender armature output with viewer-equivalent LBS samples.""" + depsgraph = bpy.context.evaluated_depsgraph_get() + max_error = 0.0 + worst = None + global_start = 1 + for entry in baked: + clip = entry["clip"] + clip_source = entry.get("source", source) + clip_index = entry["clip_index"] + inverse_bind = mapped_inverse_bind( + skeleton, clip_source.parents + ) + sample_frames = sorted( + {0, clip.num_frames // 2, clip.num_frames - 1} + ) + for local_frame in sample_frames: + scene_frame = global_start + local_frame + bpy.context.scene.frame_set(scene_frame) + bpy.context.view_layer.update() + world = compute_world_pose( + clip_source, skeleton, clip_index, local_frame + ) + palette = { + index: inverse_bind[index] @ matrix + for index, matrix in world.items() + if index in inverse_bind + } + for mesh_object, source_mesh in mesh_pairs: + skin = source_mesh.get("skin") + if not skin: + continue + evaluated_object = mesh_object.evaluated_get(depsgraph) + evaluated_mesh = evaluated_object.to_mesh() + try: + stride = max(1, len(evaluated_mesh.vertices) // 96) + for vertex_index in range( + 0, len(evaluated_mesh.vertices), stride + ): + influences = skin[vertex_index] + total = sum( + weight + for bone, weight in influences + if bone in palette + ) + if total <= 1e-6: + continue + source_point = np.array( + [ + *source_mesh["pos"][vertex_index], + 1.0, + ], + dtype=np.float64, + ) + expected_source = np.zeros(4, dtype=np.float64) + for bone, weight in influences: + matrix = palette.get(bone) + if matrix is not None: + expected_source += ( + source_point @ matrix + ) * weight + expected_source /= total + expected = point_source_to_blender( + expected_source[:3] + ) + actual = evaluated_mesh.vertices[ + vertex_index + ].co + error = float((actual - expected).length) + if error > max_error: + max_error = error + worst = { + "clip": clip.header.name, + "local_frame": local_frame, + "scene_frame": scene_frame, + "mesh": mesh_object.name, + "vertex": vertex_index, + "error_metres": error, + "actual": list(actual), + "expected": list(expected), + "source": list(source_point[:3]), + "influences": influences, + } + finally: + evaluated_object.to_mesh_clear() + global_start += clip.num_frames + return max_error, worst + + +def bake_action( + armature, + source, + skeleton, + clip_index: int, + clip, + mapped_indices, +): + action = bpy.data.actions.new(clip.header.name) + armature.animation_data_create() + armature.animation_data.action = action + bpy.context.preferences.edit.keyframe_new_interpolation_type = "LINEAR" + for pose_bone in armature.pose.bones: + pose_bone.rotation_mode = "QUATERNION" + + max_pose_error = 0.0 + worst_pose = None + for frame_index in range(clip.num_frames): + blender_frame = frame_index + 1 + bpy.context.scene.frame_set(blender_frame) + world = compute_world_pose( + source, skeleton, clip_index, frame_index + ) + targets = {} + for index in mapped_indices: + matrix = world.get(index) + if matrix is None: + continue + target = row_source_to_blender(matrix) + targets[index] = target + pose_bone = armature.pose.bones[ABIN.BONE_NAMES[index]] + rest = armature.data.bones[pose_bone.name].matrix_local + parent = source.parents[index] + if parent in targets: + parent_name = ABIN.BONE_NAMES[parent] + parent_rest = armature.data.bones[parent_name].matrix_local + local_rest = parent_rest.inverted() @ rest + pose_bone.matrix_basis = ( + local_rest.inverted() + @ targets[parent].inverted() + @ target + ) + else: + pose_bone.matrix_basis = rest.inverted() @ target + for index in targets: + pose_bone = armature.pose.bones[ABIN.BONE_NAMES[index]] + pose_bone.keyframe_insert( + data_path="location", frame=blender_frame, group=pose_bone.name + ) + pose_bone.keyframe_insert( + data_path="rotation_quaternion", + frame=blender_frame, + group=pose_bone.name, + ) + pose_bone.keyframe_insert( + data_path="scale", frame=blender_frame, group=pose_bone.name + ) + bpy.context.view_layer.update() + for index, target in targets.items(): + pose_bone = armature.pose.bones[ABIN.BONE_NAMES[index]] + actual = pose_bone.matrix + bone_error = max( + abs(actual[row][column] - target[row][column]) + for row in range(4) + for column in range(4) + ) + if bone_error > max_pose_error: + max_pose_error = bone_error + worst_pose = { + "frame": blender_frame, + "bone_index": index, + "bone": pose_bone.name, + "error": bone_error, + "target": [ + [target[row][column] for column in range(4)] + for row in range(4) + ], + "actual": [ + [actual[row][column] for column in range(4)] + for row in range(4) + ], + } + + return action, max_pose_error, worst_pose + + +def main() -> None: + args = parse_args() + abin_path = Path(args.abin).resolve() + reference_abin_path = ( + Path(args.reference_abin).resolve() + if args.reference_abin + else None + ) + rx2_path = Path(args.rx2).resolve() + output_path = Path(args.output).resolve() + + source = AnimSource(str(abin_path)) + extra_abin_path = ( + Path(args.extra_abin).resolve() + if args.extra_abin + else None + ) + extra_source = ( + AnimSource(str(extra_abin_path)) + if extra_abin_path is not None + else None + ) + reference_source = None + if reference_abin_path is not None: + reference_source = AnimSource(str(reference_abin_path)) + if reference_source.parents != source.parents: + raise RuntimeError( + "--reference-abin hierarchy does not match --abin" + ) + if not reference_source.ref_pose: + raise RuntimeError( + "--reference-abin does not contain a decodable RIG_TPOSE" + ) + source.ref_pose = reference_source.ref_pose + if extra_source is not None: + if extra_source.parents != source.parents: + raise RuntimeError( + "--extra-abin hierarchy does not match --abin" + ) + if not extra_source.ref_pose: + extra_source.ref_pose = source.ref_pose + elif args.extra_clip: + raise RuntimeError("--extra-clip requires --extra-abin") + skeleton = SkeletonSet(str(rx2_path)) + matches = [] + for requested_name in args.clip: + found = [ + (index, clip) + for index, clip in enumerate(source.clips) + if clip.header.name == requested_name + ] + if len(found) != 1: + raise RuntimeError( + f"Expected one clip named {requested_name!r}, " + f"found {len(found)}" + ) + clip_index, clip = found[0] + matches.append((source, abin_path, clip_index, clip)) + for requested_name in args.extra_clip: + found = [ + (index, clip) + for index, clip in enumerate(extra_source.clips) + if clip.header.name == requested_name + ] + if len(found) != 1: + raise RuntimeError( + f"Expected one extra clip named {requested_name!r}, " + f"found {len(found)}" + ) + clip_index, clip = found[0] + matches.append(( + extra_source, + extra_abin_path, + clip_index, + clip, + )) + bind_world = mapped_bind_world(skeleton, source.parents) + if not bind_world: + raise RuntimeError("RX2 skeleton did not map to the ABIN hierarchy") + + clear_scene() + armature, _, rest_error = create_armature( + bind_world, source.parents + ) + meshes, vertex_count, triangle_count = create_meshes( + armature, skeleton, source.parents + ) + baked = [] + for clip_source, clip_abin_path, clip_index, clip in matches: + action, pose_error, worst_pose = bake_action( + armature, + clip_source, + skeleton, + clip_index, + clip, + sorted(bind_world), + ) + baked.append( + { + "clip": clip, + "clip_index": clip_index, + "source": clip_source, + "source_abin": clip_abin_path, + "action": action, + "pose_error": pose_error, + "worst_pose": worst_pose, + } + ) + for entry in baked: + entry["action"].use_fake_user = True + + scene = bpy.context.scene + first_clip = baked[0]["clip"] + scene.render.fps = ( + args.preview_fps + if args.preview_fps > 0 + else max(1, round(first_clip.fps)) + ) + scene.frame_start = 1 + if len(baked) == 1: + armature.animation_data.action = baked[0]["action"] + scene.frame_end = first_clip.num_frames + scene.timeline_markers.new( + f"{first_clip.header.name} native {first_clip.fps:g} FPS", + frame=1, + ) + else: + armature.animation_data.action = None + frame_start = 1 + for index, entry in enumerate(baked, start=1): + clip = entry["clip"] + action = entry["action"] + track = armature.animation_data.nla_tracks.new() + track.name = f"{index:02d} {clip.header.name}" + strip = track.strips.new( + clip.header.name, frame_start, action + ) + strip.extrapolation = "NOTHING" + strip.blend_type = "REPLACE" + scene.timeline_markers.new( + f"{clip.header.name} native {clip.fps:g} FPS", + frame=frame_start, + ) + frame_start += clip.num_frames + scene.frame_end = frame_start - 1 + scene.frame_set(1) + + mesh_error, worst_mesh = validate_deformed_meshes( + meshes, armature, source, skeleton, baked + ) + scene.frame_set(1) + + note = bpy.data.texts.new("SKATE3_EXPORT_INFO.json") + clip_reports = [ + { + "name": entry["clip"].header.name, + "source_abin": str(entry["source_abin"]), + "native_fps": entry["clip"].fps, + "frames": entry["clip"].num_frames, + "pose_matrix_max_abs_error": entry["pose_error"], + "worst_pose": entry["worst_pose"], + } + for entry in baked + ] + report = { + "schema_version": 1, + "source_abin": str(abin_path), + "reference_abin": ( + str(reference_abin_path) + if reference_abin_path is not None + else None + ), + "extra_abin": ( + str(extra_abin_path) + if extra_abin_path is not None + else None + ), + "source_rx2": str(rx2_path), + "clips": clip_reports, + "preview_fps": scene.render.fps, + "frames": scene.frame_end, + "mapped_bones": len(bind_world), + "mesh_objects": len(meshes), + "vertices": vertex_count, + "triangles": triangle_count, + "rest_matrix_max_abs_error": rest_error, + "pose_matrix_max_abs_error": max( + entry["pose_error"] for entry in baked + ), + "mesh_deformation_max_error_metres": mesh_error, + "worst_mesh_deformation": worst_mesh, + "transform_reference": "SkateAnimViewer._pose", + "coordinate_conversion": "source Y-up row vectors -> Blender Z-up columns", + } + note.write(json.dumps(report, indent=2)) + + output_path.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(output_path)) + report_path = output_path.with_suffix(".validation.json") + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print("SKATE3_BLENDER_EXPORT " + json.dumps(report, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/vendor/skate3_anim/rx2_skeleton.py b/tools/vendor/skate3_anim/rx2_skeleton.py new file mode 100644 index 00000000..6362112c --- /dev/null +++ b/tools/vendor/skate3_anim/rx2_skeleton.py @@ -0,0 +1,1022 @@ +#!/usr/bin/env python3 +""" +Skate 2 / Skate 3 (Xbox 360) RW4 ".rx2" model container -- skeleton extractor. + +Big-endian EA RenderWare 4 ("\\x89RW4xb2\\0\\r\\n\\x1a\\n") export. + +The bone data lives in the section whose RW4 type code is 0x00EB0001. That +section is a byte-for-byte image of the engine struct ``pegasus::tRModelData`` +(confirmed against sk82_na_f.xex, which ships full C++ mangled symbols): + + struct pegasus::tRModelData { // sizeof == 0x40 + tAABB m_BBox; // +0x00 Vector4 min, Vector4 max + Matrix44 *m_pIBPMatrices; // +0x20 inverse bind pose, 64B each + tMeshData *m_pMeshTable; // +0x24 8 bytes per mesh + uint8 **m_pBoneNameTable; // +0x28 numBones * u32 offsets + uint8 *m_pBoneNameList; // +0x2C NUL-separated ASCII pool + uint16 m_iNumTotalBones; // +0x30 bones actually skinned + uint16 m_iNumMeshes; // +0x32 + uint16 m_iNumBones; // +0x34 IBP / name-table count + uint16 m_iNumIslands; // +0x36 + tAABB *m_pIslandAABBs; // +0x38 + Vector4 *m_pIslandAreas; // +0x3C + }; + +All pointer fields are stored on disk as offsets relative to the start of the +section. Bone names are plain NUL-terminated ASCII -- *not* EA's FastString36 +base-38 packing used by the sibling .abin format. + +The parent hierarchy is NOT stored in the .rx2. Sk8::RemapBones() resolves each +rx2 bone name against Andale::HierarchyData (which lives in the .abin animation +database) via HierarchyData::GetBone(FastString<30>). The parent indices emitted +here are therefore reconstructed from a canonical name table, then validated +geometrically against the bind poses. See RX2_FORMAT.md. + +Usage: python rx2_skeleton.py [...] + python rx2_skeleton.py --all +""" + +from __future__ import annotations + +import glob +import math +import os +import struct +import sys +from typing import Dict, List, Optional, Tuple + +MAGIC = b"\x89RW4xb2\x00\r\n\x1a\x0a" + +# --------------------------------------------------------------------------- +# RW4 section type codes seen in Skate .rx2 files. +# --------------------------------------------------------------------------- + +TYPE_MODEL_DATA = 0x00EB0001 # pegasus::tRModelData <- bones live here +TYPE_MESH_DESC = 0x00EB0023 # per-mesh skin descriptor + used-bone index list +TYPE_RAW_BUFFER = 0x00010031 # vertex / index buffer (lives in the GPU arena) +TYPE_VTX_DESC = 0x000200E9 # renderengine::VertexDescriptor (element list) +TYPE_VB_DESC = 0x000200EA # vertex buffer descriptor (byte size) +TYPE_IB_DESC = 0x000200EB # index buffer descriptor (byte size + count) +TYPE_VTX_DECL = 0x00020081 # small vertex-declaration summary + +TYPE_NAMES: Dict[int, str] = { + 0x00010031: "RawBuffer(vertex/index, GPU arena)", + 0x00020081: "VertexDeclSummary", + 0x000200E9: "VertexDescriptor (element list)", + 0x000200EA: "VertexBufferDesc", + 0x000200EB: "IndexBufferDesc", + 0x00EB0001: "tRModelData (bbox + IBP matrices + bone names)", + 0x00EB0005: "MaterialTable (ASCII key/value strings)", + 0x00EB0008: "ModelHeader", + 0x00EB000B: "ExternalReferenceTable (64-bit GUID imports)", + 0x00EB000D: "InstanceInfo (transform + asset path string)", + 0x00EB0023: "MeshSkinDesc (bbox + used-bone index list)", +} + +# --------------------------------------------------------------------------- +# Big-endian readers +# --------------------------------------------------------------------------- + + +def _u16(d: bytes, o: int) -> int: + return struct.unpack_from(">H", d, o)[0] + + +def _u32(d: bytes, o: int) -> int: + return struct.unpack_from(">I", d, o)[0] + + +def _f32v(d: bytes, o: int, n: int) -> Tuple[float, ...]: + return struct.unpack_from(">%df" % n, d, o) + + +def _cstr(d: bytes, o: int) -> str: + e = d.index(b"\x00", o) + return d[o:e].decode("ascii", "replace") + + +# --------------------------------------------------------------------------- +# Canonical parent table for the Skate character / board rig. +# +# Reconstructed from bone names + Andale::HierarchyData parent arrays read out +# of the shipped .abin skeletons, and cross-checked geometrically against the +# bind poses in these very files (every derived bone offset must come out at a +# plausible anatomical length -- see validate()). +# --------------------------------------------------------------------------- + +_P: Dict[str, Optional[str]] = { + "hips": None, + "spine": "hips", + "spine1": "spine", + "spine2": "spine1", + "spine3": "spine2", + "neck": "spine3", + "neck1": "neck", + "head": "neck1", + # board + "skateboard_root": None, + "truck_front": "skateboard_root", + "truck_back": "skateboard_root", + "left_wheelfront": "truck_front", + "right_wheelfront": "truck_front", + "left_wheelback": "truck_back", + "right_wheelback": "truck_back", + # face root chain + "face": "head", + "offset_jaw": "face", + "jaw": "offset_jaw", + "offset_chin": "jaw", + "chin": "offset_chin", + "tongue": "jaw", + "tonguetip": "tongue", +} + +for _s in ("left", "right"): + _P.update({ + _s + "shoulder": "spine3", + _s + "arm": _s + "shoulder", + _s + "armtwist": _s + "arm", + _s + "forearm": _s + "arm", + _s + "forearmtwist": _s + "forearm", + _s + "forearmtwist1": _s + "forearm", + _s + "hand": _s + "forearm", + _s + "upleg": "hips", + _s + "uplegtwist": _s + "upleg", + _s + "leg": _s + "upleg", + _s + "foot": _s + "leg", + _s + "toebase": _s + "foot", + _s + "inhandring": _s + "hand", + _s + "inhandpinky": _s + "hand", + }) + for _f in ("thumb", "index", "middle"): + _P[_s + "hand" + _f + "1"] = _s + "hand" + _P[_s + "hand" + _f + "2"] = _s + "hand" + _f + "1" + _P[_s + "hand" + _f + "3"] = _s + "hand" + _f + "2" + for _f in ("ring", "pinky"): + _P[_s + "hand" + _f + "1"] = _s + "inhand" + _f + _P[_s + "hand" + _f + "2"] = _s + "hand" + _f + "1" + _P[_s + "hand" + _f + "3"] = _s + "hand" + _f + "2" + +# Facial detail bones are flat children of Face in the shipped face rigs +# (their .abin HierarchyData parent array is [-1, 0, 1, -1, -1, ...]). +for _f in ( + "leftlowerlip", "lowerlip", "rightlowerlip", + "leftcheek", "leftcrease", "lefteye", "leftinnereyebrow", "leftloweyelid", + "leftmouth", "leftnose", "leftoutereyebrow", "leftupcheek", "leftupeyelid", + "leftupperlip", + "rightcheek", "rightcrease", "righteye", "rightinnereyebrow", + "rightloweyelid", "rightmouth", "rightnose", "rightoutereyebrow", + "rightupcheek", "rightupeyelid", "rightupperlip", "upperlip", +): + _P.setdefault(_f, "face") + +CANONICAL_PARENT = _P + + +# --------------------------------------------------------------------------- +# 4x4 rigid-transform helpers. +# +# Matrices are stored row-major with a row-vector convention: rows 0..2 are the +# rotation basis, row 3 is the translation, column 3 is (0,0,0,1). +# --------------------------------------------------------------------------- + +Mat4 = List[List[float]] + + +def _mat_from(m: Tuple[float, ...]) -> Mat4: + return [list(m[0:4]), list(m[4:8]), list(m[8:12]), list(m[12:16])] + + +def mat_mul(a: Mat4, b: Mat4) -> Mat4: + return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] + for i in range(4)] + + +def rigid_inverse(m: Mat4) -> Mat4: + """Inverse of a rigid row-vector matrix: R' = R^T, t' = -t * R^T.""" + r = [[m[j][i] for j in range(3)] for i in range(3)] # transpose + t = m[3][:3] + ti = [-(t[0] * r[0][i] + t[1] * r[1][i] + t[2] * r[2][i]) for i in range(3)] + return [r[0] + [0.0], r[1] + [0.0], r[2] + [0.0], list(ti) + [1.0]] + + +def mat_to_quat(m: Mat4) -> Tuple[float, float, float, float]: + """Rotation part -> quaternion (x, y, z, w).""" + t = m[0][0] + m[1][1] + m[2][2] + if t > 0.0: + s = math.sqrt(t + 1.0) * 2.0 + w = 0.25 * s + x = (m[1][2] - m[2][1]) / s + y = (m[2][0] - m[0][2]) / s + z = (m[0][1] - m[1][0]) / s + elif m[0][0] > m[1][1] and m[0][0] > m[2][2]: + s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2.0 + w = (m[1][2] - m[2][1]) / s + x = 0.25 * s + y = (m[1][0] + m[0][1]) / s + z = (m[2][0] + m[0][2]) / s + elif m[1][1] > m[2][2]: + s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2.0 + w = (m[2][0] - m[0][2]) / s + x = (m[1][0] + m[0][1]) / s + y = 0.25 * s + z = (m[2][1] + m[1][2]) / s + else: + s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2.0 + w = (m[0][1] - m[1][0]) / s + x = (m[2][0] + m[0][2]) / s + y = (m[2][1] + m[1][2]) / s + z = 0.25 * s + n = math.sqrt(x * x + y * y + z * z + w * w) or 1.0 + return (x / n, y / n, z / n, w / n) + + +# --------------------------------------------------------------------------- +# Container parsing +# --------------------------------------------------------------------------- + + +def parse_header(d: bytes) -> dict: + """RW4 container header. Offsets are absolute file offsets.""" + if d[:12] != MAGIC: + raise ValueError("not an RW4 .rx2 file (bad magic)") + hdr = { + "magic_ok": True, + "version_a": d[0x10:0x13].decode("ascii", "replace"), # "454" + "version_b": d[0x14:0x17].decode("ascii", "replace"), # "000" + "hash": _u32(d, 0x1C), + "section_count": _u32(d, 0x20), + "section_count2": _u32(d, 0x24), + "section_index_offset": _u32(d, 0x30), + "gpu_arena_offset": _u32(d, 0x44), # base of the 0x00010031 arena + "gpu_arena_size": _u32(d, 0x54), + "type_count": _u32(d, 0xCC), # inside the 0x00010005 sub-block + "type_list_offset": 0xD4, + } + return hdr + + +def parse_sections(d: bytes, hdr: dict) -> List[dict]: + """Section index: `section_count` entries of 24 bytes, big-endian: + +0x00 u32 offset (file offset, or GPU-arena offset for 0x10031) + +0x04 u32 unused (always 0 in shipped files) + +0x08 u32 size + +0x0C u32 alignment + +0x10 u32 type_index (index into the type list at 0xD4) + +0x14 u32 type_code + """ + types = [_u32(d, hdr["type_list_offset"] + 4 * i) + for i in range(hdr["type_count"] + 1)] + out: List[dict] = [] + base = hdr["section_index_offset"] + for i in range(hdr["section_count"]): + off, unused, size, align, ti, tc = struct.unpack_from( + ">6I", d, base + i * 24) + gpu = tc == TYPE_RAW_BUFFER + out.append({ + "index": i, + "offset": off, + "file_offset": (hdr["gpu_arena_offset"] + off) if gpu else off, + "size": size, + "alignment": align, + "type_index": ti, + "type_code": tc, + "type_name": TYPE_NAMES.get(tc, "unknown"), + "arena": "gpu" if gpu else "cpu", + "type_index_ok": ti < len(types) and types[ti] == tc, + }) + return out + + +def _parse_model_data(d: bytes, base: int) -> dict: + """pegasus::tRModelData at absolute file offset `base`.""" + bbox_min = _f32v(d, base + 0x00, 4) + bbox_max = _f32v(d, base + 0x10, 4) + p_ibp = _u32(d, base + 0x20) + p_mesh = _u32(d, base + 0x24) + p_names = _u32(d, base + 0x28) + p_pool = _u32(d, base + 0x2C) + n_total, n_mesh, n_bones, n_isl = struct.unpack_from(">4H", d, base + 0x30) + p_isl_bb = _u32(d, base + 0x38) + p_isl_area = _u32(d, base + 0x3C) + + if p_ibp + 64 * n_bones > p_mesh: + raise ValueError("IBP array overruns mesh table") + if (p_pool - p_names) // 4 != n_bones: + raise ValueError("bone name table size (%d) != m_iNumBones (%d)" + % ((p_pool - p_names) // 4, n_bones)) + + bones: List[dict] = [] + for i in range(n_bones): + name = _cstr(d, base + _u32(d, base + p_names + 4 * i)) + ibp = _mat_from(_f32v(d, base + p_ibp + 64 * i, 16)) + bind = rigid_inverse(ibp) + bones.append({ + "index": i, + "name": name, + "parent": -1, + "parent_name": None, + "ibp_matrix": ibp, + "bind_matrix": bind, + "bind_pos": tuple(bind[3][:3]), + "bind_quat": mat_to_quat(bind), + }) + + return { + "bbox_min": bbox_min[:3], + "bbox_max": bbox_max[:3], + "num_total_bones": n_total, + "num_meshes": n_mesh, + "num_bones": n_bones, + "num_islands": n_isl, + "off_ibp": p_ibp, + "off_mesh_table": p_mesh, + "off_name_table": p_names, + "off_name_pool": p_pool, + "off_island_bboxes": p_isl_bb, + "off_island_areas": p_isl_area, + "bones": bones, + } + + +def _parse_mesh_desc(d: bytes, base: int) -> dict: + """0x00EB0023: mesh bbox + the list of bone-palette slots this mesh skins to. + +0x00 Vector4 bbox min, +0x10 Vector4 bbox max, + +0x48 u32 used-bone count, +0x4C u32 offset to a u16[] of palette indices.""" + count = _u32(d, base + 0x48) + off = _u32(d, base + 0x4C) + return { + "bbox_min": _f32v(d, base + 0x00, 3), + "bbox_max": _f32v(d, base + 0x10, 3), + "used_bone_count": count, + "used_bone_indices": [_u16(d, base + off + 2 * i) for i in range(count)], + } + + +# --------------------------------------------------------------------------- +# Mesh geometry: vertex declaration, vertex buffer, index buffer. +# +# The 0x000200E9 section is a byte-image of ``renderengine::VertexDescriptor`` +# (layout confirmed from the executable's type info): +# +# struct renderengine::VertexDescriptor { // sizeof == 0x20 +# D3DVertexDeclaration *m_d3dVertexDeclaration; // +0x00 0 on disk +# uint32 m_typesFlags; // +0x04 +# uint16 m_numElements; // +0x08 +# int16 m_refCount; // +0x0A +# uint16 m_instanceStreams; // +0x0C +# uint16 m_pad0; // +0x0E +# Element m_elements[1]; // +0x10 (numElements) +# }; +# struct renderengine::VertexDescriptor::Element { // sizeof == 0x10 +# uint16 stream; // +0x00 +# uint16 offset; // +0x02 byte offset within the vertex +# uint32 format; // +0x04 renderengine::VertexFormat +# uint8 method; // +0x08 +# uint8 usage; // +0x09 D3DDECLUSAGE +# uint8 usageIndex; // +0x0A +# uint8 type; // +0x0B +# uint32 elementClass; // +0x0C +# }; +# +# Immediately after the element array the exporter appends one uint8 per +# element holding that element's stream stride (all equal in these files). +# --------------------------------------------------------------------------- + +# D3DDECLUSAGE, confirmed empirically (weights sum to 1, indices < palette size) +USAGE_NAMES: Dict[int, str] = { + 0: "POSITION", 1: "BLENDWEIGHT", 2: "BLENDINDICES", 3: "NORMAL", + 4: "PSIZE", 5: "TEXCOORD", 6: "TANGENT", 7: "BINORMAL", + 8: "TESSFACTOR", 9: "POSITIONT", 10: "COLOR", 11: "FOG", + 12: "DEPTH", 13: "SAMPLE", +} + +# renderengine::VertexFormat codes seen in these assets -> (name, byte size). +# Sizes are *proved* by the element offsets (they tile the stride exactly); +# the semantic names are inferred from the decoded values. +VTX_FORMATS: Dict[int, Tuple[str, int]] = { + 0x001A215A: ("SHORT4", 8), # raw int16[4] -- position + 0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights + 0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord + 0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair + 0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal +} + +# Position de-quantisation. Positions are raw int16 with a fixed scale of +# 2^-14 and a constant +0.8 m bias on Y. Derived empirically and cross-checked +# against the tRModelData / 0xEB0023 mesh bboxes on all 16 assets: every one of +# the 96 axis bounds agrees to under one quantisation step (6.1e-5 m). The +# constants are not stored anywhere in the file -- they are baked into the +# character vertex shader, so they are a property of the Sk8 character +# compressor rather than of this particular asset. +POS_SCALE = 1.0 / 16384.0 +POS_BIAS = (0.0, 0.8, 0.0) + + +def _dec_11_11_10(u: int) -> Tuple[float, float, float]: + """Signed 11/11/10 normalised, low bits first: x=[0:11] y=[11:22] z=[22:32]. + + Component order and normalisation were settled geometrically: with this + decode |tangent| has median 0.9992 and cross(tangent, binormal) agrees with + the mesh's geometric vertex normals at a median dot product of +0.9939. + Every other candidate ordering gives |t| ~ 0.83 and a dot near zero. + """ + out = [] + for shift, bits in ((0, 11), (11, 11), (22, 10)): + v = (u >> shift) & ((1 << bits) - 1) + if v >= 1 << (bits - 1): + v -= 1 << bits + out.append(v / float((1 << (bits - 1)) - 1)) + return (out[0], out[1], out[2]) + + +def _parse_vertex_descriptor(d: bytes, base: int, size: int) -> dict: + """0x000200E9 -> renderengine::VertexDescriptor.""" + n = _u16(d, base + 0x08) + elems: List[dict] = [] + for i in range(n): + o = base + 0x10 + 16 * i + stream, offset = struct.unpack_from(">2H", d, o) + fmt = _u32(d, o + 4) + method, usage, usage_index, etype = struct.unpack_from(">4B", d, o + 8) + fname, fsize = VTX_FORMATS.get(fmt, ("UNKNOWN_0x%08X" % fmt, 0)) + elems.append({ + "stream": stream, + "offset": offset, + "format": fmt, + "format_name": fname, + "size": fsize, + "method": method, + "usage": usage, + "usage_name": USAGE_NAMES.get(usage, "USAGE_%d" % usage), + "usage_index": usage_index, + "type": etype, + "element_class": _u32(d, o + 0x0C), + }) + tail = base + 0x10 + 16 * n + strides = list(d[tail:tail + n]) if tail + n <= base + size else [] + return { + "types_flags": _u32(d, base + 0x04), + "num_elements": n, + "instance_streams": _u16(d, base + 0x0C), + "elements": elems, + "stride": strides[0] if strides else 0, + "element_strides": strides, + } + + +def _parse_vb_desc(d: bytes, base: int) -> dict: + """0x000200EA. Only the byte size at +0x20 is load-bearing.""" + return {"byte_size": _u32(d, base + 0x20), "flags": _u32(d, base + 0x18)} + + +def _parse_ib_desc(d: bytes, base: int) -> dict: + """0x000200EB. +0x1C allocated byte size, +0x20 index count. + The low byte of +0x00 (2) is the index width in bytes -- consistent with + count*2 <= allocated size for every asset, exactly equal in some.""" + return { + "index_bytes": _u32(d, base + 0x00) & 0xFF, + "byte_size": _u32(d, base + 0x1C), + "index_count": _u32(d, base + 0x20), + "flags": _u32(d, base + 0x18), + } + + +def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict: + """Walk the vertex buffer once, decoding every element we understand.""" + stride = vdesc["stride"] + out: Dict[str, list] = {} + for e in vdesc["elements"]: + key = { + ("POSITION", 0): "positions", + ("BLENDWEIGHT", 0): "skin_weights", + ("BLENDINDICES", 0): "skin_indices", + ("NORMAL", 0): "normals", + ("TEXCOORD", 0): "uvs", + ("TEXCOORD", 1): "uvs2", + ("TANGENT", 0): "tangents", + ("BINORMAL", 0): "binormals", + }.get((e["usage_name"], e["usage_index"])) + if key is None: + continue + fmt, off = e["format_name"], e["offset"] + vals: list = [] + if e["usage_name"] == "POSITION" and fmt == "SHORT4": + for v in range(count): + x, y, z = struct.unpack_from(">3h", d, vb + v * stride + off) + vals.append((x * POS_SCALE + POS_BIAS[0], + y * POS_SCALE + POS_BIAS[1], + z * POS_SCALE + POS_BIAS[2])) + elif fmt == "PACKED11_11_10N": + for v in range(count): + vals.append(_dec_11_11_10(_u32(d, vb + v * stride + off))) + elif fmt == "SHORT2N": + for v in range(count): + u, w = struct.unpack_from(">2h", d, vb + v * stride + off) + vals.append((u / 32768.0, w / 32768.0)) + elif fmt == "SHORT4N": + for v in range(count): + q = struct.unpack_from(">4h", d, vb + v * stride + off) + vals.append(tuple(c / 32768.0 for c in q)) + elif fmt == "UBYTE4" and e["usage_name"] == "BLENDWEIGHT": + for v in range(count): + q = struct.unpack_from(">4B", d, vb + v * stride + off) + vals.append(tuple(c / 255.0 for c in q)) + elif fmt == "UBYTE4": + for v in range(count): + vals.append(struct.unpack_from(">4B", d, vb + v * stride + off)) + else: + continue + out[key] = vals + + return out + + +def _derive_normals(mesh: dict) -> None: + """No NORMAL element is exported -- the shader rebuilds it from the tangent + frame as cross(tangent, binormal). Validated geometrically: the result + agrees with the mesh's own area-weighted face normals at a median dot + product of +0.994. + + A handful of vertices in the low LODs ship a *degenerate* frame with + binormal == -tangent (a collapsed UV shell), where the cross product is + undefined. Those fall back to the geometric normal. + """ + t_all, b_all = mesh.get("tangents"), mesh.get("binormals") + if "normals" in mesh or not (t_all and b_all): + return + nrm: List[Optional[Tuple[float, float, float]]] = [] + degenerate: List[int] = [] + for i, (t, b) in enumerate(zip(t_all, b_all)): + n = (t[1] * b[2] - t[2] * b[1], + t[2] * b[0] - t[0] * b[2], + t[0] * b[1] - t[1] * b[0]) + L = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) + if L < 1e-3: + degenerate.append(i) + nrm.append(None) + else: + nrm.append((n[0] / L, n[1] / L, n[2] / L)) + + if degenerate: + pos, idx = mesh.get("positions"), mesh.get("indices") + acc = {i: [0.0, 0.0, 0.0] for i in degenerate} + if pos and idx: + want = set(degenerate) + for t in range(0, len(idx) - 2, 3): + tri = (idx[t], idx[t + 1], idx[t + 2]) + if not want.intersection(tri): + continue + a, b, c = pos[tri[0]], pos[tri[1]], pos[tri[2]] + u = [b[k] - a[k] for k in range(3)] + w = [c[k] - a[k] for k in range(3)] + f = (u[1] * w[2] - u[2] * w[1], + u[2] * w[0] - u[0] * w[2], + u[0] * w[1] - u[1] * w[0]) + for vi in tri: + if vi in acc: + for k in range(3): + acc[vi][k] += f[k] + for i in degenerate: + g = acc[i] + L = math.sqrt(sum(c * c for c in g)) + nrm[i] = (g[0] / L, g[1] / L, g[2] / L) if L > 1e-12 else (0.0, 1.0, 0.0) + + mesh["normals"] = nrm + mesh["normals_derived"] = True + mesh["degenerate_tangent_frames"] = degenerate + + +def _section_by_index(sections: List[dict], idx: int, want: int) -> Optional[dict]: + """Resolve a section reference stored in the 0xEB0023 mesh descriptor. + + The four u32s at +0x28..+0x34 hold section-index numbers. Every shipped + asset here uses the same section ordering, so this cannot be proved + uniquely -- we therefore verify the type code and fall back to "the first + section with the wanted type" if it does not match. + """ + if 0 <= idx < len(sections) and sections[idx]["type_code"] == want: + return sections[idx] + for s in sections: + if s["type_code"] == want: + return s + return None + + +def _attach_geometry(d: bytes, sections: List[dict], base: int, mesh: dict) -> None: + """Decode the vertex/index buffers referenced by one 0xEB0023 descriptor.""" + vdesc_s = _section_by_index(sections, _u32(d, base + 0x28), TYPE_VTX_DESC) + ibd_s = _section_by_index(sections, _u32(d, base + 0x30), TYPE_IB_DESC) + vbd_s = _section_by_index(sections, _u32(d, base + 0x34), TYPE_VB_DESC) + if not (vdesc_s and ibd_s and vbd_s): + return + + vdesc = _parse_vertex_descriptor(d, vdesc_s["file_offset"], vdesc_s["size"]) + vbd = _parse_vb_desc(d, vbd_s["file_offset"]) + ibd = _parse_ib_desc(d, ibd_s["file_offset"]) + stride = vdesc["stride"] + if not stride: + return + + # Raw GPU-arena buffers are matched to their descriptors by byte size; the + # vertex buffer always precedes the index buffer in the arena. + raws = sorted([s for s in sections if s["type_code"] == TYPE_RAW_BUFFER], + key=lambda s: s["offset"]) + vb_s = next((s for s in raws if s["size"] == vbd["byte_size"]), None) + ib_s = next((s for s in raws + if s["size"] == ibd["byte_size"] and s is not vb_s), None) + if vb_s is None and raws: + vb_s = raws[0] + if ib_s is None and len(raws) > 1: + ib_s = raws[1] + if vb_s is None or ib_s is None: + return + + count = vbd["byte_size"] // stride + mesh.update(_decode_vertices(d, vb_s["file_offset"], count, vdesc)) + mesh["vertex_count"] = count + mesh["stride"] = stride + mesh["vertex_elements"] = vdesc["elements"] + mesh["vb_padding"] = vbd["byte_size"] - count * stride + + n_idx = ibd["index_count"] + ibo = ib_s["file_offset"] + mesh["indices"] = list(struct.unpack_from(">%dH" % n_idx, d, ibo)) + mesh["index_count"] = n_idx + mesh["triangle_count"] = n_idx // 3 + mesh["ib_padding"] = ibd["byte_size"] - n_idx * 2 + + # Draw call: {primType(4 == D3DPT_TRIANGLELIST), ?, ?, indexCount}. + # The two middle fields are 0 in every shipped asset, so their meaning + # (start index / base vertex) is a guess. + dc = _u32(d, base + 0x44) + if dc: + prim, a, b, cnt = struct.unpack_from(">4I", d, base + dc) + mesh["draw_call"] = {"prim_type": prim, "field1": a, "field2": b, + "index_count": cnt} + + _derive_normals(mesh) # needs the index buffer for the degenerate cases + + # Map palette-local blend indices onto the file's bone array. + pal = mesh.get("used_bone_indices") or [] + if pal and "skin_indices" in mesh: + mesh["skin_bones"] = [tuple(pal[i] if i < len(pal) else 0 for i in q) + for q in mesh["skin_indices"]] + + +def _assign_parents(bones: List[dict]) -> None: + """Fill in `parent` / `parent_name` from CANONICAL_PARENT. + + A file only carries the bones its meshes skin to, so a bone's canonical + parent is often absent from the palette. In that case we walk up the + canonical chain to the nearest ancestor that *is* present, which keeps the + emitted tree connected and acyclic. `parent_name` always reports the true + canonical direct parent, present or not. + """ + by_name = {b["name"].lower(): b["index"] for b in bones} + for b in bones: + key = b["name"].lower() + direct = CANONICAL_PARENT.get(key, "__unknown__") + b["parent_name"] = None if direct in (None, "__unknown__") else direct + b["parent_known"] = direct != "__unknown__" + cur, seen = direct, set() + while cur not in (None, "__unknown__") and cur not in seen: + seen.add(cur) + if cur in by_name: + b["parent"] = by_name[cur] + break + cur = CANONICAL_PARENT.get(cur, None) + else: + b["parent"] = -1 + + +def parse_rx2(path: str, geometry: bool = True) -> dict: + """Parse an .rx2 RW4 container and extract its skeleton and mesh geometry. + + Returns a dict with: + 'header' -- container header fields + 'sections' -- list of section-index entries + 'model' -- tRModelData fields (bbox, counts, sub-offsets) + 'bones' -- [{'name','index','parent','parent_name','bind_matrix', + 'ibp_matrix','bind_pos','bind_quat'}] + 'meshes' -- 0x00EB0023 descriptors incl. used-bone index lists, plus + (when ``geometry``) the decoded buffers: + 'positions' [(x, y, z)] model space, metres + 'indices' flat triangle-list vertex indices + 'normals' [(x, y, z)] unit; derived cross(T, B) + 'tangents' / 'binormals' [(x, y, z)] unit + 'uvs' (and 'uvs2' on the hair meshes) + 'skin_indices' [(i, i, i, i)] into 'used_bone_indices' + 'skin_bones' [(b, b, b, b)] into 'bones' + 'skin_weights' [(w, w, w, w)] sums to 1.0 + 'vertex_count', 'triangle_count', 'stride', + 'vertex_elements', 'draw_call' + + Pass ``geometry=False`` to skip the buffers (skeleton-only, as before). + """ + with open(path, "rb") as fh: + d = fh.read() + + hdr = parse_header(d) + sections = parse_sections(d, hdr) + + model: Optional[dict] = None + meshes: List[dict] = [] + for s in sections: + if s["type_code"] == TYPE_MODEL_DATA and model is None: + model = _parse_model_data(d, s["file_offset"]) + elif s["type_code"] == TYPE_MESH_DESC: + m = _parse_mesh_desc(d, s["file_offset"]) + if geometry: + _attach_geometry(d, sections, s["file_offset"], m) + meshes.append(m) + + if model is None: + raise ValueError("no 0x00EB0001 tRModelData section in %s" % path) + + bones = model.pop("bones") + _assign_parents(bones) + + return { + "path": path, + "file_size": len(d), + "header": hdr, + "sections": sections, + "model": model, + "bones": bones, + "meshes": meshes, + } + + +def parse_rx2_meshes(path: str) -> List[dict]: + """Convenience wrapper: just the renderable meshes of an .rx2.""" + return parse_rx2(path, geometry=True)["meshes"] + + +# --------------------------------------------------------------------------- +# Validation -- these checks are the ground truth for "did we decode it right". +# --------------------------------------------------------------------------- + + +def validate_geometry(res: dict) -> List[str]: + """Geometric sanity checks on the decoded vertex/index buffers.""" + errs: List[str] = [] + for mi, m in enumerate(res["meshes"]): + tag = "mesh %d" % mi + pos = m.get("positions") + idx = m.get("indices") + if pos is None or idx is None: + errs.append("%s: no geometry decoded" % tag) + continue + + # Positions must sit inside the mesh bbox (1% slack on the extent). + lo, hi = m["bbox_min"], m["bbox_max"] + slack = [0.01 * (hi[k] - lo[k]) + 1e-4 for k in range(3)] + bad = 0 + for p in pos: + if any(not (lo[k] - slack[k] <= p[k] <= hi[k] + slack[k]) + for k in range(3)): + bad += 1 + if bad: + errs.append("%s: %d/%d vertices outside bbox" % (tag, bad, len(pos))) + + if len(idx) % 3: + errs.append("%s: index count %d not a multiple of 3" + % (tag, len(idx))) + oor = [i for i in idx if i >= len(pos)] + if oor: + errs.append("%s: %d indices >= vertex count %d" + % (tag, len(oor), len(pos))) + continue + + # Mean triangle edge length must be mm-to-cm scale. + tot = n = 0.0 + for t in range(0, len(idx) - 2, 3): + a, b, c = (pos[idx[t]], pos[idx[t + 1]], pos[idx[t + 2]]) + for u, v in ((a, b), (b, c), (c, a)): + tot += math.sqrt(sum((u[k] - v[k]) ** 2 for k in range(3))) + n += 1 + mean_edge = tot / n if n else 0.0 + m["mean_edge_length"] = mean_edge + if not (1e-4 < mean_edge < 0.25): + errs.append("%s: mean edge length %.6g m out of plausible range" + % (tag, mean_edge)) + + # Degenerate triangles are a red flag for a wrong index stride. + degen = sum(1 for t in range(0, len(idx) - 2, 3) + if len({idx[t], idx[t + 1], idx[t + 2]}) < 3) + if degen: + errs.append("%s: %d degenerate triangles" % (tag, degen)) + + w = m.get("skin_weights") + if w: + worst = max(abs(sum(q) - 1.0) for q in w) + m["max_weight_error"] = worst + if worst > 0.01: + errs.append("%s: blend weights sum off by %.4f" % (tag, worst)) + si = m.get("skin_indices") + if si and m.get("used_bone_count"): + mx = max(max(q) for q in si) + if mx >= m["used_bone_count"]: + errs.append("%s: blend index %d >= used-bone count %d" + % (tag, mx, m["used_bone_count"])) + for key in ("normals", "tangents", "binormals"): + vecs = m.get(key) + if not vecs: + continue + worst = max(abs(math.sqrt(sum(c * c for c in v)) - 1.0) + for v in vecs) + if worst > 0.05: + errs.append("%s: %s off unit length by %.4f" % (tag, key, worst)) + uv = m.get("uvs") + if uv and not all(-4.0 < c < 4.0 for q in uv for c in q): + errs.append("%s: texcoords out of plausible range" % tag) + return errs + + +def validate(res: dict) -> List[str]: + """Return a list of problems; empty means every sanity check passed.""" + errs: List[str] = [] + bones = res["bones"] + mdl = res["model"] + + for s in res["sections"]: + if not s["type_index_ok"]: + errs.append("section %d: type_index does not resolve to type_code" + % s["index"]) + if s["arena"] == "cpu" and s["offset"] + s["size"] > res["file_size"]: + errs.append("section %d overruns file" % s["index"]) + + if len(bones) != mdl["num_bones"]: + errs.append("bone count mismatch") + + for mi, m in enumerate(res["meshes"]): + if m["used_bone_count"] > mdl["num_bones"]: + errs.append("mesh %d: used-bone count %d > numBones %d" + % (mi, m["used_bone_count"], mdl["num_bones"])) + bad = [i for i in m["used_bone_indices"] if i >= mdl["num_bones"]] + if bad: + errs.append("mesh %d: used-bone indices out of range: %s" + % (mi, bad[:8])) + if m["used_bone_indices"] != sorted(m["used_bone_indices"]): + errs.append("mesh %d: used-bone index list not ascending" % mi) + + lo, hi = mdl["bbox_min"], mdl["bbox_max"] + if not all(lo[i] <= hi[i] for i in range(3)): + errs.append("bbox min > max") + if not all(-10.0 < v < 10.0 for v in list(lo) + list(hi)): + errs.append("bbox out of human scale: %s %s" % (lo, hi)) + + for b in bones: + m = b["ibp_matrix"] + for r in range(3): + n = math.sqrt(sum(c * c for c in m[r][:3])) + if abs(n - 1.0) > 1e-3: + errs.append("%s: IBP row %d not unit length (%.5f)" + % (b["name"], r, n)) + det = (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])) + if abs(det - 1.0) > 1e-3: + errs.append("%s: IBP determinant %.5f != 1" % (b["name"], det)) + if abs(m[3][3] - 1.0) > 1e-6 or any(abs(m[r][3]) > 1e-6 + for r in range(3)): + errs.append("%s: IBP last column not (0,0,0,1)" % b["name"]) + + q = b["bind_quat"] + qn = math.sqrt(sum(c * c for c in q)) + if abs(qn - 1.0) > 1e-4: + errs.append("%s: bind quat not unit (%.5f)" % (b["name"], qn)) + + p = b["bind_pos"] + if not all(-4.0 < v < 4.0 for v in p): + errs.append("%s: bind pos out of human scale %s" % (b["name"], p)) + if not (lo[0] - 1.0 <= p[0] <= hi[0] + 1.0 + and lo[1] - 1.5 <= p[1] <= hi[1] + 1.5 + and lo[2] - 1.0 <= p[2] <= hi[2] + 1.0): + errs.append("%s: bind pos %s far outside model bbox" + % (b["name"], p)) + + if not b["parent_known"]: + errs.append("%s: no canonical parent known" % b["name"]) + + # Tree validity: acyclic, and every edge a plausible bone length. + for b in bones: + p, seen = b["parent"], set() + while p != -1: + if p in seen: + errs.append("%s: parent cycle" % b["name"]) + break + seen.add(p) + p = bones[p]["parent"] + if b["parent"] != -1: + a, c = bones[b["parent"]]["bind_pos"], b["bind_pos"] + dist = math.sqrt(sum((c[i] - a[i]) ** 2 for i in range(3))) + if dist > 0.75: + errs.append("%s -> %s: implausible bone length %.3f m" + % (bones[b["parent"]]["name"], b["name"], dist)) + + if any("positions" in m for m in res["meshes"]): + errs.extend(validate_geometry(res)) + return errs + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def print_report(res: dict) -> bool: + mdl = res["model"] + errs = validate(res) # also fills in per-mesh derived statistics + print("=" * 78) + print("%s (%d bytes)" % (res["path"], res["file_size"])) + h = res["header"] + print(" RW4 v%s/%s hash=0x%08X sections=%d @0x%X gpu arena 0x%X+0x%X" + % (h["version_a"], h["version_b"], h["hash"], h["section_count"], + h["section_index_offset"], h["gpu_arena_offset"], + h["gpu_arena_size"])) + print(" sections:") + for s in res["sections"]: + print(" [%2d] %-4s off=0x%06X size=0x%06X align=0x%02X " + "type=0x%08X %s" + % (s["index"], s["arena"], s["offset"], s["size"], + s["alignment"], s["type_code"], s["type_name"])) + print(" tRModelData: numBones=%d numTotalBones=%d numMeshes=%d " + "numIslands=%d" % (mdl["num_bones"], mdl["num_total_bones"], + mdl["num_meshes"], mdl["num_islands"])) + print(" bbox min=(%.4f, %.4f, %.4f) max=(%.4f, %.4f, %.4f) [metres]" + % (tuple(mdl["bbox_min"]) + tuple(mdl["bbox_max"]))) + for mi, m in enumerate(res["meshes"]): + print(" mesh skins %d palette slots: %s" + % (m["used_bone_count"], m["used_bone_indices"])) + if "positions" in m: + print(" mesh %d: %d verts (stride %d) / %d tris, mean edge " + "%.2f mm, max weight err %.5f" + % (mi, m["vertex_count"], m["stride"], m["triangle_count"], + 1000.0 * m.get("mean_edge_length", 0.0), + m.get("max_weight_error", 0.0))) + print(" layout: " + ", ".join( + "+%d %s[%d]=%s" % (e["offset"], e["usage_name"], + e["usage_index"], e["format_name"]) + for e in m["vertex_elements"])) + + print(" bone tree (bind position in model space, metres):") + kids: Dict[int, List[int]] = {} + for b in res["bones"]: + kids.setdefault(b["parent"], []).append(b["index"]) + + def walk(i: int, depth: int) -> None: + b = res["bones"][i] + p = b["bind_pos"] + q = b["bind_quat"] + print(" %s%-3d %-24s parent=%-3d pos=(%8.4f,%8.4f,%8.4f) " + "quat=(%6.3f,%6.3f,%6.3f,%6.3f)" + % (" " * depth, b["index"], b["name"], b["parent"], + p[0], p[1], p[2], q[0], q[1], q[2], q[3])) + for c in kids.get(i, []): + walk(c, depth + 1) + + for r in kids.get(-1, []): + walk(r, 0) + + if errs: + print(" VALIDATION FAILED (%d):" % len(errs)) + for e in errs[:20]: + print(" ! " + e) + else: + print(" validation: OK (rigid IBP matrices, unit quats, human-scale " + "positions, acyclic tree, plausible bone lengths)") + return not errs + + +def main(argv: List[str]) -> int: + args = argv[1:] + if not args: + print(__doc__) + return 1 + if args[0] == "--all": + root = args[1] if len(args) > 1 else "." + paths = sorted(glob.glob(os.path.join(root, "**", "*.rx2"), + recursive=True)) + else: + paths = args + + ok = failed = 0 + for p in paths: + try: + if print_report(parse_rx2(p)): + ok += 1 + else: + failed += 1 + except Exception as exc: # noqa: BLE001 + print("=" * 78) + print("%s: PARSE ERROR: %s" % (p, exc)) + failed += 1 + print("=" * 78) + print("%d/%d file(s) parsed and validated" % (ok, ok + failed)) + return 0 if failed == 0 else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/vendor/university/LICENSE-PROJECT.md b/tools/vendor/university/LICENSE-PROJECT.md new file mode 100644 index 00000000..d0c0192d --- /dev/null +++ b/tools/vendor/university/LICENSE-PROJECT.md @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 Skate 3 Custom Engine Layer contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the original Custom Engine Layer software and associated documentation +files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license applies only to original project-owned additions. It does not +relicense Skate3Recomp, rexglue, third-party dependencies, Electronic Arts +game data, or assets embedded by map authors. diff --git a/tools/vendor/university/tools/blender_owned_map/analyze_skate.py b/tools/vendor/university/tools/blender_owned_map/analyze_skate.py new file mode 100644 index 00000000..a3aecae1 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/analyze_skate.py @@ -0,0 +1,1100 @@ +#!/usr/bin/env python3 +"""Report the byte layout and integrity-relevant counts of a SKATE package.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import zlib +from dataclasses import dataclass +from pathlib import Path +try: + from compression import zstd +except ImportError: + zstd = None + + +VERTEX_BYTES = 44 +VERTEX_BYTES_V12 = 56 +COLLISION_TRIANGLE_BYTES_V10 = 44 +COLLISION_TRIANGLE_BYTES_V11 = 48 +RETAIL_COLLISION_ARCHIVE_MAGIC = b"RWCMSET1" + + +def _inflate(stored: bytes, expected: int, label: str) -> bytes: + try: + decoded = zlib.decompress(stored) + except zlib.error as error: + raise PackageError(f"{label} DEFLATE payload is invalid") from error + if len(decoded) != expected: + raise PackageError( + f"{label} decoded to {len(decoded)} bytes; expected {expected}" + ) + return decoded + + +def _unzstd(stored: bytes, expected: int, label: str) -> bytes: + if zstd is None: + raise PackageError( + f"{label} uses Zstandard; run this analyzer with Python 3.14+" + ) + try: + decoded = zstd.decompress(stored) + except Exception as error: + raise PackageError(f"{label} Zstandard payload is invalid") from error + if len(decoded) != expected: + raise PackageError( + f"{label} decoded to {len(decoded)} bytes; expected {expected}" + ) + return decoded + + +def _undo_rgba_filter(data: bytes, expected: int, label: str) -> bytes: + source = Reader(data) + width = source.u32(f"{label} filtered width") + height = source.u32(f"{label} filtered height") + row_size = width * 4 + if row_size * height != expected: + raise PackageError(f"{label} filtered dimensions are invalid") + result = bytearray(expected) + for y in range(height): + filter_type = source.take(1, f"{label} row filter")[0] + if filter_type > 4: + raise PackageError(f"{label} row filter is invalid") + filtered = source.take(row_size, f"{label} filtered row") + row_offset = y * row_size + for x, value in enumerate(filtered): + left = result[row_offset + x - 4] if x >= 4 else 0 + above = result[row_offset - row_size + x] if y else 0 + upper_left = ( + result[row_offset - row_size + x - 4] + if y and x >= 4 + else 0 + ) + if filter_type == 1: + predictor = left + elif filter_type == 2: + predictor = above + elif filter_type == 3: + predictor = (left + above) // 2 + elif filter_type == 4: + prediction = left + above - upper_left + distances = ( + abs(prediction - left), + abs(prediction - above), + abs(prediction - upper_left), + ) + predictor = (left, above, upper_left)[distances.index( + min(distances) + )] + else: + predictor = 0 + result[row_offset + x] = (value + predictor) & 0xFF + if source.offset != len(data): + raise PackageError(f"{label} filtered payload has trailing bytes") + return bytes(result) + + +def _undo_vertex_soa(data: bytes, expected: int, label: str) -> bytes: + if expected % VERTEX_BYTES_V12 or len(data) != expected: + raise PackageError(f"{label} vertex streams are invalid") + result = bytearray(expected) + fields = ((0, 12), (12, 12), (24, 8), (32, 8), (40, 4), + (44, 8), (52, 4)) + source = 0 + count = expected // VERTEX_BYTES_V12 + for field_offset, field_size in fields: + for vertex in range(count): + destination = vertex * VERTEX_BYTES_V12 + field_offset + result[destination : destination + field_size] = data[ + source : source + field_size + ] + source += field_size + return bytes(result) + + +def _read_varuint(data: bytes, offset: int, label: str) -> tuple[int, int]: + value = 0 + for shift in range(0, 64, 7): + if offset >= len(data): + raise PackageError(f"{label} varint is truncated") + byte = data[offset] + offset += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value, offset + raise PackageError(f"{label} varint is invalid") + + +def _undo_index_delta(data: bytes, expected: int, label: str) -> bytes: + if expected % 4: + raise PackageError(f"{label} index size is invalid") + result = bytearray(expected) + previous = 0 + offset = 0 + for index in range(expected // 4): + zigzag, offset = _read_varuint(data, offset, label) + delta = (zigzag >> 1) ^ -(zigzag & 1) + current = previous + delta + if not 0 <= current <= 0xFFFFFFFF: + raise PackageError(f"{label} index delta is invalid") + struct.pack_into(" bytes: + if expected % COLLISION_TRIANGLE_BYTES_V11: + raise PackageError(f"{label} collision size is invalid") + source = Reader(data) + vertex_count = source.u32(f"{label} collision vertex count") + vertices = source.take(vertex_count * 12, f"{label} collision vertices") + result = bytearray(expected) + for triangle in range(expected // COLLISION_TRIANGLE_BYTES_V11): + destination = triangle * COLLISION_TRIANGLE_BYTES_V11 + for corner in range(3): + vertex = source.u32(f"{label} collision vertex index") + if vertex >= vertex_count: + raise PackageError(f"{label} collision vertex is invalid") + result[ + destination + corner * 12 : destination + corner * 12 + 12 + ] = vertices[vertex * 12 : vertex * 12 + 12] + result[destination + 36 : destination + 48] = source.take( + 12, f"{label} collision fields" + ) + if source.offset != len(data): + raise PackageError(f"{label} indexed collision has trailing bytes") + return bytes(result) + + +class PackageError(ValueError): + """Raised when a package cannot be parsed without guessing.""" + + +@dataclass +class Reader: + data: bytes + offset: int = 0 + + def take(self, size: int, label: str) -> bytes: + if size < 0 or self.offset + size > len(self.data): + raise PackageError(f"{label} extends past the end of the package") + start = self.offset + self.offset += size + return self.data[start : self.offset] + + def u32(self, label: str) -> int: + return struct.unpack(" int: + return struct.unpack(" str: + size = self.u32(f"{label} length") + try: + return self.take(size, label).decode("utf-8") + except UnicodeDecodeError as error: + raise PackageError(f"{label} is not valid UTF-8") from error + + def skip(self, size: int, label: str) -> None: + self.take(size, label) + + def stored( + self, + expected_size: int, + label: str, + references: list[bytes] | None = None, + ) -> bytes: + method = self.u32(f"{label} storage method") + stored_size = self.u32(f"{label} stored size") + stored = self.take(stored_size, f"{label} payload") + if method == 0: + if stored_size != expected_size: + raise PackageError( + f"{label} raw size is {stored_size}; " + f"expected {expected_size}" + ) + return stored + if method == 11: + if references is None or len(stored) != 4: + raise PackageError(f"{label} texture reference is invalid") + source = struct.unpack("= len(references): + raise PackageError(f"{label} texture reference is out of range") + decoded = references[source] + if len(decoded) != expected_size: + raise PackageError(f"{label} texture reference size differs") + return decoded + if method in (1, 2): + return ( + _inflate(stored, expected_size, label) + if method == 1 + else _unzstd(stored, expected_size, label) + ) + if method not in range(3, 11) or len(stored) < 4: + raise PackageError(f"{label} uses unsupported method {method}") + transformed_size = struct.unpack(" None: + sections[name] = reader.offset - start + + +def _summarize_retail_collision_archive( + payload: bytes, +) -> dict[str, object]: + archive = Reader(payload) + if archive.take(8, "RWCM archive magic") != RETAIL_COLLISION_ARCHIVE_MAGIC: + raise PackageError("RWCM extension archive magic is invalid") + mesh_count = archive.u32("RWCM mesh count") + if mesh_count == 0: + raise PackageError("RWCM extension contains no meshes") + mesh_payload_bytes = 0 + for index in range(mesh_count): + name_size = archive.u32(f"RWCM mesh {index} name length") + if name_size == 0 or name_size > 4096: + raise PackageError(f"RWCM mesh {index} name is invalid") + try: + archive.take( + name_size, f"RWCM mesh {index} name" + ).decode("utf-8") + except UnicodeDecodeError as error: + raise PackageError( + f"RWCM mesh {index} name is not valid UTF-8" + ) from error + mesh_size = archive.u32(f"RWCM mesh {index} payload size") + if mesh_size < 96: + raise PackageError(f"RWCM mesh {index} payload is invalid") + archive.skip(mesh_size, f"RWCM mesh {index} payload") + mesh_payload_bytes += mesh_size + if archive.offset != len(payload): + raise PackageError("RWCM extension archive has trailing bytes") + return { + "meshes": mesh_count, + "decoded_bytes": len(payload), + "mesh_payload_bytes": mesh_payload_bytes, + "sha256": hashlib.sha256(payload).hexdigest(), + } + + +def analyze_package( + path: Path, + *, + include_payloads: bool = False, +) -> dict[str, object]: + reader = Reader(path.read_bytes()) + sections: dict[str, int] = {} + + start = reader.offset + magic = reader.take(8, "magic") + if magic not in ( + b"SKATE08\0", + b"SKATE09\0", + b"SKATE10\0", + b"SKATE11\0", + b"SKATE12\0", + b"SKATE13\0", + b"SKATE14\0", + b"SKATE15\0", + ): + raise PackageError( + f"unsupported magic {magic!r}; expected SKATE v8-v15" + ) + version = int(magic[5:7]) + if reader.u32("endian marker") != 0x12345678: + raise PackageError("invalid endian marker") + semantic_metadata_start = reader.offset + map_name = reader.string("map name") + # Spawn/environment metadata between the map name and count table. + reader.skip(49 * 4, "map metadata") + counts = struct.unpack("<9I", reader.take(9 * 4, "count table")) + ( + material_count, + texture_count, + vertex_count, + index_count, + collision_count, + rail_count, + door_count, + light_count, + route_count, + ) = counts + semantic_metadata = reader.data[semantic_metadata_start : reader.offset - 36] + _section(sections, reader, "header_and_map_metadata", start) + + start = reader.offset + package_reader = reader + if version >= 15: + decoded_material_size = reader.u32("material block decoded size") + reader = Reader( + reader.stored(decoded_material_size, "material block") + ) + material_start = reader.offset + material_alpha_modes = {0: 0, 1: 0, 2: 0} + material_depth_layers = {0: 0, 1: 0, 2: 0, 3: 0} + retail_family_counts: dict[int, int] = {} + retail_binding_count = 0 + retail_parameter_count = 0 + material_records: list[dict[str, object]] = [] + for index in range(material_count): + name = reader.string(f"material {index} name") + fields = reader.take(76, f"material {index} fields") + alpha_mode = struct.unpack_from("= 13: + depth_layer = reader.u32( + f"material {index} presentation depth layer" + ) + elif alpha_mode == 2: + depth_layer = 3 + elif any( + token in name.lower() + for token in ( + "sign", "poster", "billboard", "adbord", "advert", + "banner", "logo", "decal", "graffiti", "sticker", + "plaque", "letter", "neon", + ) + ): + depth_layer = 2 + elif alpha_mode == 1: + depth_layer = 1 + else: + depth_layer = 0 + if depth_layer not in material_depth_layers: + raise PackageError( + f"material {index} uses invalid depth layer {depth_layer}" + ) + material_depth_layers[depth_layer] += 1 + record["presentation_depth_layer"] = depth_layer + if version >= 12: + retail_enabled = reader.u32( + f"material {index} retail enabled" + ) != 0 + record["retail_enabled"] = retail_enabled + if retail_enabled: + record["retail_material_guid"] = reader.u64( + f"material {index} retail guid" + ) + record["retail_material_handle"] = reader.u32( + f"material {index} retail handle" + ) + record["retail_material_group_index"] = struct.unpack( + "= 15: + if reader.offset != len(reader.data): + raise PackageError("material block has trailing bytes") + reader = package_reader + _section(sections, reader, "materials", start) + + start = reader.offset + texture_decoded_bytes = 0 + texture_payloads: list[bytes] = [] + texture_dimensions: list[dict[str, object]] = [] + texture_digest = hashlib.sha256() + for index in range(texture_count): + name = reader.string(f"texture {index} name") + width = reader.u32(f"texture {index} width") + height = reader.u32(f"texture {index} height") + color_space = reader.u32(f"texture {index} color space") + expected = width * height * 4 + if version >= 9: + rgba8 = reader.stored( + expected, f"texture {index}", texture_payloads + ) + else: + byte_count = reader.u32(f"texture {index} byte count") + if byte_count != expected: + raise PackageError( + f"texture {index} has {byte_count} bytes; expected {expected}" + ) + rgba8 = reader.take(byte_count, f"texture {index} RGBA8") + texture_payloads.append(rgba8) + encoded_name = name.encode("utf-8") + texture_digest.update(struct.pack("= 12 else VERTEX_BYTES + vertex_byte_count = vertex_count * vertex_stride + if version >= 9: + vertex_bytes = reader.stored( + vertex_byte_count, "visual vertex block" + ) + else: + vertex_bytes = reader.take(vertex_byte_count, "visual vertices") + _section(sections, reader, "visual_vertices", start) + + start = reader.offset + index_byte_count = index_count * 4 + if version >= 9: + index_bytes = reader.stored(index_byte_count, "visual index block") + else: + index_bytes = reader.take(index_byte_count, "visual indices") + if index_count: + indices = struct.unpack(f"<{index_count}I", index_bytes) + maximum_index = max(indices) + if maximum_index >= vertex_count: + raise PackageError( + f"visual index {maximum_index} exceeds vertex count {vertex_count}" + ) + else: + indices = () + maximum_index = None + _section(sections, reader, "visual_indices", start) + + start = reader.offset + collision_triangle_bytes = ( + COLLISION_TRIANGLE_BYTES_V11 + if version >= 11 + else COLLISION_TRIANGLE_BYTES_V10 + ) + collision_byte_count = collision_count * collision_triangle_bytes + if version >= 9: + collision_bytes = reader.stored( + collision_byte_count, "collision block" + ) + else: + collision_bytes = reader.take( + collision_byte_count, "collision triangles" + ) + _section(sections, reader, "collision", start) + + authored_features_start = reader.offset + start = reader.offset + grind_section_start = reader.offset + native_grind_segments = 0 + for index in range(rail_count): + reader.string(f"rail {index} name") + reader.u32(f"rail {index} closed") + representation = ( + reader.u32(f"rail {index} representation") + if version >= 10 + else 0 + ) + if representation == 0: + point_count = reader.u32(f"rail {index} point count") + reader.skip(point_count * 12, f"rail {index} points") + elif representation == 1 and version >= 10: + reader.u64(f"rail {index} retail spline id") + reader.u64(f"rail {index} retail type signature") + reader.u32(f"rail {index} retail flags") + reader.u32(f"rail {index} retail trailing word") + segment_count = reader.u32(f"rail {index} segment count") + reader.skip( + segment_count * 120, + f"rail {index} native segments", + ) + native_grind_segments += segment_count + else: + raise PackageError( + f"rail {index} uses unsupported representation " + f"{representation}" + ) + _section(sections, reader, "grind_rails", start) + grind_section_bytes = reader.data[grind_section_start : reader.offset] + + start = reader.offset + for index in range(door_count): + reader.string(f"door {index} name") + reader.skip(28 * 4, f"door {index} float fields") + reader.u32(f"door {index} surface") + door_vertex_count = reader.u32(f"door {index} vertex count") + door_index_count = reader.u32(f"door {index} index count") + door_collision_count = reader.u32(f"door {index} collision count") + reader.skip( + door_vertex_count * vertex_stride, + f"door {index} vertices", + ) + door_indices = reader.take( + door_index_count * 4, + f"door {index} indices", + ) + if door_index_count: + maximum_door_index = max( + struct.unpack(f"<{door_index_count}I", door_indices) + ) + if maximum_door_index >= door_vertex_count: + raise PackageError( + f"door {index} index {maximum_door_index} exceeds " + f"vertex count {door_vertex_count}" + ) + reader.skip( + door_collision_count * collision_triangle_bytes, + f"door {index} collision", + ) + _section(sections, reader, "hinged_doors", start) + + start = reader.offset + for index in range(light_count): + reader.string(f"light {index} name") + reader.skip(4 + 14 * 4, f"light {index} fields") + _section(sections, reader, "local_lights", start) + + start = reader.offset + for index in range(route_count): + reader.string(f"route {index} name") + reader.skip(4 + 4 + 4 + 4, f"route {index} fields") + point_count = reader.u32(f"route {index} point count") + reader.skip(point_count * 12, f"route {index} points") + _section(sections, reader, "npc_routes", start) + + extension_tags: list[str] = [] + map_objects: list[dict[str, object]] = [] + break_groups: dict[int, dict[str, object]] = {} + blender_material_compatibility: list[dict[str, object]] = [] + embedded_retail_collision: dict[str, object] | None = None + embedded_retail_collision_bytes = b"" + dynamic_lighting_enabled_by_default = True + extension_bytes = b"" + if version >= 12: + start = reader.offset + extension_count = reader.u32("extension count") + for extension_index in range(extension_count): + tag = reader.take( + 4, f"extension {extension_index} tag" + ).decode("ascii", errors="replace") + schema = reader.u32(f"extension {extension_index} schema") + decoded_size = reader.u32( + f"extension {extension_index} decoded size" + ) + payload = reader.stored( + decoded_size, f"extension {extension_index}" + ) + extension_tags.append(tag) + if tag == "WCFG": + if schema != 1 or len(payload) != 4: + raise PackageError("WCFG extension is invalid") + dynamic_lighting_enabled_by_default = ( + struct.unpack("= 2: + grind_count = object_reader.u32( + f"MOBJ object {object_index} grind count" + ) + grind_indices = [ + object_reader.u32( + f"MOBJ object {object_index} grind {grind}" + ) + for grind in range(grind_count) + ] + physics = { + "type": 0, + "shape": 0, + "density": 100.0, + "friction": 0.55, + "restitution": 0.05, + "linear_damping": 0.05, + "angular_damping": 0.15, + "gravity_scale": 1.0, + "enable_sleep": True, + "initially_awake": True, + "break_group": 0, + "break_speed_threshold": 2.5, + "break_impulse_scale": 0.45, + "break_angular_impulse": 0.08, + "break_gravity_scale": 1.0, + } + if schema >= 3: + values = struct.unpack( + " 2 + or values[1] > 2 + or values[8] > 1 + or values[9] > 1 + or not 0.0 < values[2] <= 100000.0 + or not 0.0 <= values[3] <= 2.0 + or not 0.0 <= values[4] <= 1.0 + or not 0.0 <= values[5] <= 100.0 + or not 0.0 <= values[6] <= 100.0 + or not -10.0 <= values[7] <= 10.0 + ): + raise PackageError( + f"MOBJ object {object_index} has invalid " + "physics metadata" + ) + physics = { + "type": values[0], + "shape": values[1], + "density": values[2], + "friction": values[3], + "restitution": values[4], + "linear_damping": values[5], + "angular_damping": values[6], + "gravity_scale": values[7], + "enable_sleep": bool(values[8]), + "initially_awake": bool(values[9]), + "break_group": 0, + "break_speed_threshold": 2.5, + "break_impulse_scale": 0.45, + "break_angular_impulse": 0.08, + "break_gravity_scale": 1.0, + } + map_objects.append( + { + "id": object_id, + "name": name, + "origin": origin, + "first_index": object_first_index, + "index_count": object_index_count, + "first_collision": object_first_collision, + "collision_count": object_collision_count, + "grind_indices": grind_indices, + "physics": physics, + } + ) + if object_reader.offset != len(payload): + raise PackageError("MOBJ has trailing bytes") + elif tag == "BGRP": + if schema != 1: + raise PackageError( + f"BGRP schema {schema} is unsupported" + ) + break_reader = Reader(payload) + break_count = break_reader.u32("BGRP object count") + for break_index in range(break_count): + object_id, group, speed, impulse, spin, gravity = ( + struct.unpack( + "= 12: + base_values = struct.unpack("<10fI2f4b", record) + floats = ( + *base_values[:10], + *base_values[11:13], + *(value / 127.0 for value in base_values[13:17]), + ) + material_id = base_values[10] + else: + base_values = struct.unpack("<10fI", record) + floats = base_values[:10] + material_id = base_values[10] + quantized = [ + round(value * 1_000_000.0) + for value in floats + ] + try: + quantized_triangle_digest.update( + struct.pack( + f"<{len(floats)}qI", + *quantized, + material_id, + ) + ) + except struct.error as error: + raise PackageError( + f"vertex {index} contains values outside the analyzer's " + f"quantized range: {floats!r}" + ) from error + positions = [ + struct.unpack_from("<3f", vertex_bytes, offset) + for offset in range(0, len(vertex_bytes), vertex_stride) + ] + bounds_min = [ + min(position[axis] for position in positions) for axis in range(3) + ] + bounds_max = [ + max(position[axis] for position in positions) for axis in range(3) + ] + + result = { + "path": str(path.resolve()), + "version": version, + "map_name": map_name, + "file_bytes": len(reader.data), + "counts": { + "materials": material_count, + "textures": texture_count, + "vertices": vertex_count, + "indices": index_count, + "collision_triangles": collision_count, + "grind_rails": rail_count, + "native_grind_segments": native_grind_segments, + "hinged_doors": door_count, + "local_lights": light_count, + "npc_routes": route_count, + "retail_texture_bindings": retail_binding_count, + "retail_material_parameters": retail_parameter_count, + }, + "sections": sections, + "material_alpha_modes": { + "opaque": material_alpha_modes[0], + "mask": material_alpha_modes[1], + "blend": material_alpha_modes[2], + }, + "material_depth_layers": material_depth_layers, + "retail_shader_families": retail_family_counts, + "extension_tags": extension_tags, + "dynamic_lighting_enabled_by_default": + dynamic_lighting_enabled_by_default, + "embedded_retail_collision": embedded_retail_collision, + "map_objects": map_objects, + "blender_material_compatibility": ( + blender_material_compatibility + ), + "texture_decoded_bytes": texture_decoded_bytes, + "texture_dimensions": texture_dimensions, + "maximum_visual_index": maximum_index, + "integrity": { + "semantic_metadata_sha256": hashlib.sha256( + semantic_metadata + ).hexdigest(), + "materials_sha256": hashlib.sha256(material_bytes).hexdigest(), + "decoded_textures_sha256": texture_digest.hexdigest(), + "expanded_visual_triangles_sha256": ( + triangle_digest.hexdigest() + ), + "expanded_visual_triangles_1e6_sha256": ( + quantized_triangle_digest.hexdigest() + ), + "collision_sha256": hashlib.sha256( + collision_bytes + ).hexdigest(), + "grind_rails_sha256": hashlib.sha256( + grind_section_bytes + ).hexdigest(), + "extensions_sha256": hashlib.sha256( + extension_bytes + ).hexdigest(), + "authored_features_sha256": hashlib.sha256( + reader.data[authored_features_start : reader.offset] + ).hexdigest(), + "bounds_min": bounds_min, + "bounds_max": bounds_max, + }, + } + if include_payloads: + result["_materials"] = material_records + result["_vertex_bytes"] = vertex_bytes + result["_indices"] = indices + result["_collision_bytes"] = collision_bytes + result["_embedded_retail_collision_bytes"] = ( + embedded_retail_collision_bytes + ) + return result + + +def _format_bytes(value: int) -> str: + units = ("B", "KiB", "MiB", "GiB") + amount = float(value) + for unit in units: + if amount < 1024.0 or unit == units[-1]: + return f"{amount:.2f} {unit}" + amount /= 1024.0 + raise AssertionError("unreachable") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument( + "--json", + action="store_true", + help="write machine-readable JSON instead of a table", + ) + args = parser.parse_args() + result = analyze_package(args.package) + if args.json: + print(json.dumps(result, indent=2)) + return 0 + + total = int(result["file_bytes"]) + print(f"{result['map_name']} — SKATE v{result['version']}") + print(f"File: {_format_bytes(total)} ({total:,} bytes)") + print() + print(f"{'Section':<26} {'Bytes':>14} {'Share':>9}") + for name, size_value in result["sections"].items(): + size = int(size_value) + share = 100.0 * size / max(1, total) + print(f"{name:<26} {size:>14,} {share:>8.2f}%") + print() + print("Counts:", json.dumps(result["counts"], sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/blender_owned_map/audit_blend_lod.py b/tools/vendor/university/tools/blender_owned_map/audit_blend_lod.py new file mode 100644 index 00000000..41fccc43 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/audit_blend_lod.py @@ -0,0 +1,69 @@ +"""Print large-map object, LOD, and retained-source metadata statistics.""" + +from __future__ import annotations + +import collections +import re + +import bpy + + +def main() -> None: + meshes = [obj for obj in bpy.data.objects if obj.type == "MESH"] + exported = [ + obj + for obj in meshes + if bool(obj.get("ow_export_visual", True)) and not obj.hide_render + ] + lod_pattern = re.compile(r"(^|[_. -])lod[0-9]+($|[_. -])") + lod_named = [ + obj for obj in meshes if lod_pattern.search(obj.name.lower()) + ] + metadata = collections.Counter( + key + for obj in meshes + for key in obj.keys() + if any( + marker in key.lower() + for marker in ("lod", "sector", "stream", "cluster") + ) + ) + stream_files = collections.Counter( + str(obj.get("skate3_stream_file", "")) + for obj in meshes + if obj.get("skate3_stream_file", "") + ) + print( + "SKATE_BLEND_LARGE_MAP_STATS", + f"meshes={len(meshes)}", + f"exported={len(exported)}", + f"polygons={sum(len(obj.data.polygons) for obj in exported)}", + f"lod_named={len(lod_named)}", + f"lod_exported={sum(obj in exported for obj in lod_named)}", + f"metadata={dict(metadata)}", + f"stream_files={len(stream_files)}", + ) + print( + "SKATE_BLEND_LOD_SAMPLES", + [obj.name for obj in lod_named[:50]], + ) + print("SKATE_BLEND_STREAM_FILES", stream_files.most_common(50)) + print( + "SKATE_BLEND_OBJECT_SAMPLES", + [ + { + "name": obj.name, + "polygons": len(obj.data.polygons), + "properties": { + key: obj.get(key) + for key in obj.keys() + if key != "_RNA_UI" + }, + } + for obj in meshes[:20] + ], + ) + + +if __name__ == "__main__": + main() diff --git a/tools/vendor/university/tools/blender_owned_map/compare_skate.py b/tools/vendor/university/tools/blender_owned_map/compare_skate.py new file mode 100644 index 00000000..1fe213c4 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/compare_skate.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Compare two SKATE packages after decoding storage optimizations.""" + +from __future__ import annotations + +import argparse +import json +import struct +from pathlib import Path + +from analyze_skate import PackageError, analyze_package + + +def compare_packages( + before_path: Path, + after_path: Path, +) -> tuple[list[str], dict[str, object], dict[str, object], float]: + before = analyze_package(before_path, include_payloads=True) + after = analyze_package(after_path, include_payloads=True) + failures: list[str] = [] + for name in ( + "materials", + "textures", + "indices", + "collision_triangles", + "grind_rails", + "hinged_doors", + "local_lights", + "npc_routes", + ): + if before["counts"][name] != after["counts"][name]: + failures.append( + f"{name}: {before['counts'][name]} != " + f"{after['counts'][name]}" + ) + for name, value in before["integrity"].items(): + if ( + name.startswith("expanded_visual_triangles") + or name.startswith("bounds_") + ): + continue + if value != after["integrity"][name]: + failures.append( + f"{name}: {json.dumps(value)} != " + f"{json.dumps(after['integrity'][name])}" + ) + + maximum_float_delta = 0.0 + before_vertices = before["_vertex_bytes"] + after_vertices = after["_vertex_bytes"] + before_v12 = int(before["version"]) >= 12 + after_v12 = int(after["version"]) >= 12 + before_stride = 56 if before_v12 else 44 + after_stride = 56 if after_v12 else 44 + for corner, (before_index, after_index) in enumerate( + zip(before["_indices"], after["_indices"], strict=True) + ): + before_record = struct.unpack_from( + "<10fI2f4b" if before_v12 else "<10fI", + before_vertices, + before_index * before_stride, + ) + after_record = struct.unpack_from( + "<10fI2f4b" if after_v12 else "<10fI", + after_vertices, + after_index * after_stride, + ) + if before_record[10] != after_record[10]: + failures.append( + f"visual corner {corner} material: " + f"{before_record[10]} != {after_record[10]}" + ) + break + before_floats = ( + ( + *before_record[:10], + *before_record[11:13], + *(value / 127.0 for value in before_record[13:17]), + ) + if before_v12 + else before_record[:10] + ) + after_floats = ( + ( + *after_record[:10], + *after_record[11:13], + *(value / 127.0 for value in after_record[13:17]), + ) + if after_v12 + else after_record[:10] + ) + deltas = tuple( + abs(left - right) + for left, right in zip( + before_floats, + after_floats, + strict=True, + ) + ) + delta = max(deltas) + maximum_float_delta = max(maximum_float_delta, delta) + if delta > 1.0e-6: + field_names = ( + "position.x", + "position.y", + "position.z", + "normal.x", + "normal.y", + "normal.z", + "uv0.x", + "uv0.y", + "uv1.x", + "uv1.y", + "uv2.x", + "uv2.y", + "binormal.x", + "binormal.y", + "binormal.z", + "handedness", + ) + field_index = deltas.index(delta) + failures.append( + f"visual corner {corner} maximum float delta " + f"{delta:.9g} exceeds 1e-6 in {field_names[field_index]}: " + f"{before_floats[field_index]:.9g} != " + f"{after_floats[field_index]:.9g}" + ) + break + + return failures, before, after, maximum_float_delta + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("before", type=Path) + parser.add_argument("after", type=Path) + args = parser.parse_args() + failures, before, after, maximum_float_delta = compare_packages( + args.before, args.after + ) + before_size = int(before["file_bytes"]) + after_size = int(after["file_bytes"]) + reduction = 100.0 * (before_size - after_size) / before_size + if failures: + print("SKATE_SEMANTIC_MISMATCH") + for failure in failures: + print(f"- {failure}") + return 1 + print( + "SKATE_SEMANTIC_MATCH", + f"before_bytes={before_size}", + f"after_bytes={after_size}", + f"reduction_percent={reduction:.2f}", + f"before_vertices={before['counts']['vertices']}", + f"after_vertices={after['counts']['vertices']}", + f"triangles={after['counts']['indices'] // 3}", + f"maximum_float_delta={maximum_float_delta:.9g}", + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except PackageError as error: + raise SystemExit(f"SKATE_PACKAGE_ERROR {error}") from error diff --git a/tools/vendor/university/tools/blender_owned_map/create_bake_showcase.py b/tools/vendor/university/tools/blender_owned_map/create_bake_showcase.py new file mode 100644 index 00000000..c92b9a32 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_bake_showcase.py @@ -0,0 +1,1132 @@ +"""Build, bake, save, and export the small SKATE lighting showcase.""" + +from __future__ import annotations + +from pathlib import Path +import math +import sys + +import bpy +from mathutils import Vector + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from owned_world_material_addon.exporter import export_scene # noqa: E402 +import owned_world_material_addon as owned_world_materials # noqa: E402 + +owned_world_materials.register() + + +BLEND_PATH = ROOT / "owned" / "maps" / "source" / "blender_bake_showcase.blend" +PACKAGE_PATH = ROOT / "owned" / "maps" / "blender_bake_showcase.skate" +TEXTURE_DIR = ROOT / "owned" / "maps" / "source" / "blender_bake_showcase_textures" + + +def reset_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for block in ( + bpy.data.meshes, + bpy.data.curves, + bpy.data.materials, + bpy.data.images, + bpy.data.lights, + ): + for item in list(block): + block.remove(item) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + + +def make_collection(name: str) -> bpy.types.Collection: + collection = bpy.data.collections.new(name) + bpy.context.scene.collection.children.link(collection) + return collection + + +def move_to(obj: bpy.types.Object, collection: bpy.types.Collection) -> None: + for current in list(obj.users_collection): + current.objects.unlink(obj) + collection.objects.link(obj) + + +def make_texture( + name: str, + first: tuple[float, float, float], + second: tuple[float, float, float], + cells: int, + stripe: bool = False, + *, + alpha: float = 1.0, + cutout_grid: bool = False, +) -> bpy.types.Image: + size = 256 + image = bpy.data.images.new(name, width=size, height=size, alpha=True) + pixels = [0.0] * (size * size * 4) + for y in range(size): + for x in range(size): + checker = ((x * cells // size) + (y * cells // size)) & 1 + color = second if checker else first + if stripe: + color = second if ((x + y) // 28) & 1 else first + grain = (((x * 17 + y * 31) % 29) / 28.0 - 0.5) * 0.055 + offset = (y * size + x) * 4 + pixels[offset + 0] = max(0.0, min(1.0, color[0] + grain)) + pixels[offset + 1] = max(0.0, min(1.0, color[1] + grain)) + pixels[offset + 2] = max(0.0, min(1.0, color[2] + grain)) + pixel_alpha = alpha + if cutout_grid: + pixel_alpha = ( + 1.0 if x % 32 < 5 or y % 32 < 5 else 0.0 + ) + pixels[offset + 3] = pixel_alpha + image.pixels.foreach_set(pixels) + image.update() + # Generated image buffers did not survive Blender's pack/save cycle in + # 5.0 and reopened as zero RGB. Persist them as ordinary project-owned + # PNG assets, then reload them through Blender's file-backed image path + # before any material or package references them. + TEXTURE_DIR.mkdir(parents=True, exist_ok=True) + texture_path = TEXTURE_DIR / f"{name}.png" + image.filepath_raw = str(texture_path) + image.file_format = "PNG" + image.save() + bpy.data.images.remove(image) + loaded = bpy.data.images.load(str(texture_path), check_existing=False) + loaded.name = name + return loaded + + +def make_normal_texture( + name: str, + strength: float, + frequency: float, +) -> bpy.types.Image: + size = 256 + image = bpy.data.images.new(name, width=size, height=size, alpha=True) + image.colorspace_settings.name = "Non-Color" + pixels = [0.0] * (size * size * 4) + for y in range(size): + for x in range(size): + u = x / size * math.tau * frequency + v = y / size * math.tau * frequency + nx = math.sin(u) * strength + ny = math.cos(v) * strength + nz = math.sqrt(max(0.0, 1.0 - nx * nx - ny * ny)) + offset = (y * size + x) * 4 + pixels[offset:offset + 4] = ( + nx * 0.5 + 0.5, + ny * 0.5 + 0.5, + nz * 0.5 + 0.5, + 1.0, + ) + image.pixels.foreach_set(pixels) + image.update() + TEXTURE_DIR.mkdir(parents=True, exist_ok=True) + path = TEXTURE_DIR / f"{name}.png" + image.filepath_raw = str(path) + image.file_format = "PNG" + image.save() + bpy.data.images.remove(image) + loaded = bpy.data.images.load(str(path), check_existing=False) + loaded.name = name + loaded.colorspace_settings.name = "Non-Color" + return loaded + + +def make_orm_texture( + name: str, + roughness: float, + metallic: float, + *, + ao: float = 1.0, +) -> bpy.types.Image: + size = 64 + image = bpy.data.images.new(name, width=size, height=size, alpha=True) + image.colorspace_settings.name = "Non-Color" + pixels = [0.0] * (size * size * 4) + for y in range(size): + for x in range(size): + variation = (((x * 13 + y * 19) % 17) / 16.0 - 0.5) * 0.05 + offset = (y * size + x) * 4 + pixels[offset:offset + 4] = ( + max(0.0, min(1.0, ao + variation * 0.25)), + max(0.0, min(1.0, roughness + variation)), + max(0.0, min(1.0, metallic)), + 1.0, + ) + image.pixels.foreach_set(pixels) + image.update() + TEXTURE_DIR.mkdir(parents=True, exist_ok=True) + path = TEXTURE_DIR / f"{name}.png" + image.filepath_raw = str(path) + image.file_format = "PNG" + image.save() + bpy.data.images.remove(image) + loaded = bpy.data.images.load(str(path), check_existing=False) + loaded.name = name + loaded.colorspace_settings.name = "Non-Color" + return loaded + + +def make_material( + name: str, + image: bpy.types.Image, + color: tuple[float, float, float], + roughness: float, + *, + flags: int = 1, + emissive: float = 0.0, + normal_image: bpy.types.Image | None = None, + orm_image: bpy.types.Image | None = None, + emissive_image: bpy.types.Image | None = None, + metallic: float = 0.0, + alpha_mode: int = 0, + alpha_cutoff: float = 0.5, + audio_surface: int = 3, + physics_surface: int = 1, + surface_pattern: int = 0, + collision_enabled: bool = True, +) -> bpy.types.Material: + material = bpy.data.materials.new(name) + material.use_nodes = True + material.diffuse_color = (*color, 1.0) + material["ow_albedo_image"] = image.name + material["ow_display_color"] = color + material["ow_roughness"] = roughness + material["ow_friction"] = 0.84 + material["ow_restitution"] = 0.0 + material["ow_flags"] = flags + material["ow_emissive"] = emissive + material["ow_baked_strength"] = 0.92 + material["ow_normal_image"] = ( + normal_image.name if normal_image is not None else "" + ) + material["ow_orm_image"] = ( + orm_image.name if orm_image is not None else "" + ) + material["ow_emissive_image"] = ( + emissive_image.name if emissive_image is not None else "" + ) + material["ow_metallic"] = metallic + material["ow_alpha_mode"] = alpha_mode + material["ow_alpha_cutoff"] = alpha_cutoff + material["ow_audio_surface"] = audio_surface + material["ow_physics_surface"] = physics_surface + material["ow_surface_pattern"] = surface_pattern + material["ow_collision_enabled"] = collision_enabled + nodes = material.node_tree.nodes + links = material.node_tree.links + nodes.clear() + output = nodes.new("ShaderNodeOutputMaterial") + shader = nodes.new("ShaderNodeBsdfPrincipled") + texture = nodes.new("ShaderNodeTexImage") + texture.image = image + texture.interpolation = "Linear" + links.new(texture.outputs["Color"], shader.inputs["Base Color"]) + shader.inputs["Roughness"].default_value = roughness + shader.inputs["Metallic"].default_value = metallic + if alpha_mode != 0: + links.new(texture.outputs["Alpha"], shader.inputs["Alpha"]) + try: + material.surface_render_method = ( + "DITHERED" if alpha_mode == 2 else "DITHERED" + ) + except Exception: + pass + if normal_image is not None: + normal_texture = nodes.new("ShaderNodeTexImage") + normal_texture.image = normal_image + normal_texture.interpolation = "Linear" + normal_node = nodes.new("ShaderNodeNormalMap") + links.new(normal_texture.outputs["Color"], normal_node.inputs["Color"]) + links.new(normal_node.outputs["Normal"], shader.inputs["Normal"]) + if emissive > 0.0: + emission_color = shader.inputs.get("Emission Color") + emission_strength = shader.inputs.get("Emission Strength") + if emission_color is not None: + emission_color.default_value = (*color, 1.0) + if emission_strength is not None: + emission_strength.default_value = emissive + links.new(shader.outputs["BSDF"], output.inputs["Surface"]) + settings = material.owned_world + settings.audio_surface = str(audio_surface) + settings.physics_surface = str(physics_surface) + settings.surface_pattern = str(surface_pattern) + settings.collision_enabled = collision_enabled + settings.alpha_mode = str(alpha_mode) + settings.alpha_cutoff = alpha_cutoff + settings.roughness = roughness + settings.metallic = metallic + settings.normal_image = normal_image + settings.orm_image = orm_image + settings.emissive_image = emissive_image + return material + + +def metric_uv( + obj: bpy.types.Object, + layer_name: str = "UVMap", + metres_per_tile: float = 4.0, +) -> None: + """Author deterministic metre-scaled UV0 instead of fitting each object.""" + mesh = obj.data + uv_layer = mesh.uv_layers.get(layer_name) + if uv_layer is None: + uv_layer = mesh.uv_layers.new(name=layer_name) + normal_matrix = obj.matrix_world.to_3x3().inverted().transposed() + for polygon in mesh.polygons: + normal = (normal_matrix @ polygon.normal).normalized() + axis = max(range(3), key=lambda index: abs(normal[index])) + for loop_index in polygon.loop_indices: + loop = mesh.loops[loop_index] + point = obj.matrix_world @ mesh.vertices[loop.vertex_index].co + if axis == 2: + uv = (point.x, point.y) + elif axis == 0: + uv = (point.y, point.z) + else: + uv = (point.x, point.z) + uv_layer.data[loop_index].uv = ( + uv[0] / metres_per_tile, + uv[1] / metres_per_tile, + ) + + +def collision_copy( + source: bpy.types.Object, + collision_collection: bpy.types.Collection, + material_name: str, + suffix: str = "", +) -> bpy.types.Object: + collision = source.copy() + collision.data = source.data.copy() + collision.name = f"COL_{source.name}{suffix}" + collision["ow_material"] = material_name + collision.hide_render = True + collision.display_type = "WIRE" + collision_collection.objects.link(collision) + return collision + + +def add_collision_mesh( + name: str, + vertices: list[tuple[float, float, float]], + faces: list[tuple[int, ...]], + material_name: str, + collision_collection: bpy.types.Collection, + *, + upward_surface: bool = False, +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"COL_{name}_Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.validate(verbose=True) + mesh.update() + obj = bpy.data.objects.new(f"COL_{name}", mesh) + obj["ow_material"] = material_name + obj["ow_upward_surface"] = upward_surface + obj.hide_render = True + obj.display_type = "WIRE" + collision_collection.objects.link(obj) + return obj + + +def add_collision_box( + name: str, + location: tuple[float, float, float], + dimensions: tuple[float, float, float], + material_name: str, + collision_collection: bpy.types.Collection, +) -> bpy.types.Object: + """Create a box proxy without a bottom face buried in the floor.""" + cx, cy, cz = location + hx, hy, hz = (dimension * 0.5 for dimension in dimensions) + vertices = [ + (cx - hx, cy - hy, cz - hz), + (cx - hx, cy + hy, cz - hz), + (cx + hx, cy + hy, cz - hz), + (cx + hx, cy - hy, cz - hz), + (cx - hx, cy - hy, cz + hz), + (cx - hx, cy + hy, cz + hz), + (cx + hx, cy + hy, cz + hz), + (cx + hx, cy - hy, cz + hz), + ] + faces = [ + (4, 7, 6, 5), # top, +Z + (0, 4, 5, 1), + (3, 2, 6, 7), + (0, 3, 7, 4), + (1, 5, 6, 2), + ] + return add_collision_mesh( + name, vertices, faces, material_name, collision_collection + ) + + +def add_surface_tile( + name: str, + x0: float, + x1: float, + y0: float, + y1: float, + material: bpy.types.Material, + visual_collection: bpy.types.Collection, + collision_collection: bpy.types.Collection, +) -> bpy.types.Object: + visual_vertices = [ + (x0, y0, 0.012), + (x1, y0, 0.012), + (x1, y1, 0.012), + (x0, y1, 0.012), + ] + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(visual_vertices, [], [(0, 1, 2, 3)]) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + visual_collection.objects.link(obj) + obj.data.materials.append(material) + metric_uv(obj, metres_per_tile=2.0) + add_collision_mesh( + name, + [(x0, y0, 0.0), (x1, y0, 0.0), + (x1, y1, 0.0), (x0, y1, 0.0)], + [(0, 1, 2, 3)], + material.name, + collision_collection, + upward_surface=True, + ) + return obj + + +def add_box( + name: str, + location: tuple[float, float, float], + dimensions: tuple[float, float, float], + material: bpy.types.Material, + visual_collection: bpy.types.Collection, + collision_collection: bpy.types.Collection | None, + *, + bevel: float = 0.0, +) -> bpy.types.Object: + bpy.ops.mesh.primitive_cube_add(location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = dimensions + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + move_to(obj, visual_collection) + obj.data.materials.append(material) + if collision_collection is not None: + add_collision_box( + name, location, dimensions, material.name, collision_collection + ) + if bevel > 0.0: + modifier = obj.modifiers.new("Edge softness", "BEVEL") + modifier.width = bevel + modifier.segments = 3 + bpy.context.view_layer.objects.active = obj + bpy.ops.object.modifier_apply(modifier=modifier.name) + metric_uv(obj) + return obj + + +def add_wedge( + name: str, + x0: float, + x1: float, + y0: float, + y1: float, + low: float, + high: float, + material: bpy.types.Material, + visual_collection: bpy.types.Collection, + collision_collection: bpy.types.Collection, +) -> bpy.types.Object: + vertices = [ + (x0, y0, low), + (x0, y1, low), + (x1, y1, low), + (x1, y0, low), + (x1, y0, high), + (x1, y1, high), + ] + faces = [ + (0, 4, 5, 1), + (3, 4, 5, 2), + (0, 3, 2, 1), + (0, 4, 3), + (1, 2, 5), + ] + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + visual_collection.objects.link(obj) + obj.data.materials.append(material) + add_collision_mesh( + name, + [vertices[index] for index in (0, 4, 5, 1)], + [(0, 1, 2, 3)], + material.name, + collision_collection, + upward_surface=True, + ) + metric_uv(obj) + return obj + + +def add_curved_ramp( + name: str, + center_x: float, + y0: float, + y1: float, + length: float, + height: float, + material: bpy.types.Material, + visual_collection: bpy.types.Collection, + collision_collection: bpy.types.Collection, +) -> bpy.types.Object: + segments = 14 + vertices = [] + faces = [] + for index in range(segments + 1): + amount = index / segments + x = center_x + length * amount + z = height * (1.0 - math.cos(amount * math.pi * 0.5)) + vertices.extend(((x, y0, z), (x, y1, z))) + for index in range(segments): + base = index * 2 + faces.append((base, base + 2, base + 3, base + 1)) + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + visual_collection.objects.link(obj) + obj.data.materials.append(material) + collision = collision_copy(obj, collision_collection, material.name) + collision["ow_upward_surface"] = True + metric_uv(obj) + return obj + + +def add_stair_set( + name: str, + x0: float, + y0: float, + y1: float, + step_depth: float, + step_height: float, + step_count: int, + deck_depth: float, + material: bpy.types.Material, + visual_collection: bpy.types.Collection, + collision_collection: bpy.types.Collection, +) -> bpy.types.Object: + """Create one continuous stair shell with no intersecting step boxes.""" + vertices: list[tuple[float, float, float]] = [] + faces: list[tuple[int, ...]] = [] + + def quad( + a: tuple[float, float, float], + b: tuple[float, float, float], + c: tuple[float, float, float], + d: tuple[float, float, float], + ) -> tuple[int, int, int, int]: + base = len(vertices) + vertices.extend((a, b, c, d)) + return base, base + 1, base + 2, base + 3 + + previous_height = 0.0 + current_x = x0 + for step in range(step_count): + height = step_height * (step + 1) + next_x = current_x + step_depth + faces.append( + quad( + (current_x, y0, previous_height), + (current_x, y0, height), + (current_x, y1, height), + (current_x, y1, previous_height), + ) + ) + faces.append( + quad( + (current_x, y0, height), + (next_x, y0, height), + (next_x, y1, height), + (current_x, y1, height), + ) + ) + previous_height = height + current_x = next_x + + deck_end = current_x + deck_depth + faces.append( + quad( + (current_x, y0, previous_height), + (deck_end, y0, previous_height), + (deck_end, y1, previous_height), + (current_x, y1, previous_height), + ) + ) + faces.append( + quad( + (deck_end, y0, 0.0), + (deck_end, y1, 0.0), + (deck_end, y1, previous_height), + (deck_end, y0, previous_height), + ) + ) + + # Tile each side in height bands. This gives Blender only convex quads, + # and every neighbouring edge has matching endpoints for native welding. + side_sections = [ + (x0 + step * step_depth, x0 + (step + 1) * step_depth, step + 1) + for step in range(step_count) + ] + side_sections.append((current_x, deck_end, step_count)) + for side_y, positive_y in ((y0, False), (y1, True)): + for section_x0, section_x1, bands in side_sections: + for band in range(bands): + low = band * step_height + high = (band + 1) * step_height + if positive_y: + faces.append( + quad( + (section_x0, side_y, low), + (section_x0, side_y, high), + (section_x1, side_y, high), + (section_x1, side_y, low), + ) + ) + else: + faces.append( + quad( + (section_x0, side_y, low), + (section_x1, side_y, low), + (section_x1, side_y, high), + (section_x0, side_y, high), + ) + ) + + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.validate(verbose=True) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + visual_collection.objects.link(obj) + obj.data.materials.append(material) + metric_uv(obj) + add_collision_mesh( + name, vertices, faces, material.name, collision_collection + ) + return obj + + +def add_grind_curve( + name: str, + points: list[tuple[float, float, float]], + collection: bpy.types.Collection, +) -> None: + curve = bpy.data.curves.new(name, "CURVE") + curve.dimensions = "3D" + spline = curve.splines.new("POLY") + spline.points.add(len(points) - 1) + for target, point in zip(spline.points, points): + target.co = (*point, 1.0) + obj = bpy.data.objects.new(name, curve) + collection.objects.link(obj) + + +def join_visuals( + visual_collection: bpy.types.Collection, +) -> bpy.types.Object: + visuals = [ + obj for obj in visual_collection.objects if obj.type == "MESH" + ] + bpy.ops.object.select_all(action="DESELECT") + for obj in visuals: + obj.select_set(True) + bpy.context.view_layer.objects.active = visuals[0] + bpy.ops.object.join() + joined = bpy.context.object + joined.name = "OW_Visual_Showcase" + if joined.data.uv_layers.get("Lightmap") is None: + joined.data.uv_layers.new(name="Lightmap") + joined.data.uv_layers.active = joined.data.uv_layers["Lightmap"] + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.012) + bpy.ops.object.mode_set(mode="OBJECT") + return joined + + +def prepare_shared_lightmap_uvs( + visual_collection: bpy.types.Collection, +) -> list[bpy.types.Object]: + """Pack one lightmap atlas while preserving independent mesh objects.""" + visuals = [ + obj for obj in visual_collection.objects if obj.type == "MESH" + ] + if not visuals: + raise RuntimeError("OW_VISUAL contains no mesh objects") + bpy.ops.object.select_all(action="DESELECT") + for obj in visuals: + layer = obj.data.uv_layers.get("Lightmap") + if layer is None: + layer = obj.data.uv_layers.new(name="Lightmap") + obj.data.uv_layers.active = layer + obj.select_set(True) + bpy.context.view_layer.objects.active = visuals[0] + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="SELECT") + # Blender's multi-object edit mode packs every selected object's islands + # into one shared atlas without collapsing authored object identity. + bpy.ops.uv.smart_project( + angle_limit=math.radians(66.0), island_margin=0.012 + ) + bpy.ops.object.mode_set(mode="OBJECT") + return visuals + + +def add_bake_target( + materials: list[bpy.types.Material], + lightmap: bpy.types.Image, +) -> None: + for material in materials: + material["ow_lightmap_image"] = lightmap.name + nodes = material.node_tree.nodes + for node in nodes: + node.select = False + target = nodes.new("ShaderNodeTexImage") + target.name = "OW_BakedIndirect" + target.label = "OW Baked Indirect (UV: Lightmap)" + target.image = lightmap + target.interpolation = "Linear" + target.select = True + nodes.active = target + + +def persist_lightmap( + lightmap: bpy.types.Image, + materials: list[bpy.types.Material], +) -> bpy.types.Image: + TEXTURE_DIR.mkdir(parents=True, exist_ok=True) + lightmap_path = TEXTURE_DIR / "LM_BakedIndirect_Showcase.exr" + exported_name = lightmap.name + lightmap.filepath_raw = str(lightmap_path) + lightmap.file_format = "OPEN_EXR" + lightmap.save() + + # Replace the transient bake target with a normal file-backed image. + # This makes both the editable .blend and later standalone exports + # deterministic after Blender has been closed and reopened. + lightmap.name = f"{exported_name}_BakeBuffer" + loaded = bpy.data.images.load(str(lightmap_path), check_existing=False) + loaded.name = exported_name + loaded.colorspace_settings.name = "Non-Color" + for material in materials: + material["ow_lightmap_image"] = loaded.name + target = material.node_tree.nodes.get("OW_BakedIndirect") + if target is None: + raise RuntimeError( + f"material {material.name!r} lost its bake target" + ) + target.image = loaded + bpy.data.images.remove(lightmap) + return loaded + + +def configure_bake( + targets: bpy.types.Object | list[bpy.types.Object], +) -> None: + scene = bpy.context.scene + scene.render.engine = "BLENDER_EEVEE" + # Blender 5 removed the old Cycles bake path. Eevee's light-probe bake is + # not an image bake, so use the supported workbench-independent emission + # bake setup when Cycles is unavailable and Cycles otherwise. + try: + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 24 + scene.cycles.max_bounces = 4 + scene.cycles.diffuse_bounces = 3 + except Exception: + scene.render.engine = "BLENDER_EEVEE" + scene.render.bake.margin = 10 + scene.render.bake.use_clear = True + scene.render.bake.use_pass_direct = False + scene.render.bake.use_pass_indirect = True + scene.render.bake.use_pass_color = False + bpy.ops.object.select_all(action="DESELECT") + objects = targets if isinstance(targets, list) else [targets] + if not objects: + raise RuntimeError("indirect bake has no target objects") + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + + +def build_scene() -> None: + reset_scene() + visual = make_collection("OW_VISUAL") + collision = make_collection("OW_COLLISION") + grinds = make_collection("OW_GRIND") + + concrete_tex = make_texture( + "T_Concrete", + (0.34, 0.36, 0.39), + (0.48, 0.50, 0.53), + 12, + ) + orange_tex = make_texture( + "T_OrangePaint", + (0.72, 0.16, 0.035), + (0.95, 0.38, 0.055), + 8, + stripe=True, + ) + brick_tex = make_texture( + "T_DarkBrick", + (0.11, 0.055, 0.045), + (0.27, 0.10, 0.065), + 18, + ) + metal_tex = make_texture( + "T_Metal", + (0.19, 0.22, 0.25), + (0.34, 0.38, 0.42), + 24, + ) + cyan_tex = make_texture( + "T_CyanEmitter", + (0.02, 0.85, 1.0), + (0.10, 1.0, 0.94), + 4, + ) + magenta_tex = make_texture( + "T_OrangeEmitter", + (1.0, 0.08, 0.015), + (1.0, 0.42, 0.025), + 4, + ) + asphalt_tex = make_texture( + "T_Asphalt", (0.055, 0.06, 0.07), (0.12, 0.13, 0.14), 28 + ) + wood_tex = make_texture( + "T_WoodRamp", (0.28, 0.105, 0.035), (0.58, 0.28, 0.08), 10, + stripe=True, + ) + tile_tex = make_texture( + "T_CeramicTile", (0.14, 0.38, 0.68), (0.72, 0.88, 0.96), 8 + ) + grass_tex = make_texture( + "T_Grass", (0.035, 0.18, 0.045), (0.12, 0.38, 0.09), 32 + ) + ice_tex = make_texture( + "T_Ice", (0.38, 0.68, 0.86), (0.72, 0.92, 1.0), 6 + ) + glass_tex = make_texture( + "T_Glass", (0.08, 0.35, 0.48), (0.22, 0.68, 0.78), 4, + alpha=0.30, + ) + fence_tex = make_texture( + "T_FenceCutout", (0.08, 0.09, 0.10), (0.72, 0.76, 0.80), 8, + cutout_grid=True, + ) + hazard_tex = make_texture( + "T_Hazard", (0.55, 0.015, 0.01), (1.0, 0.72, 0.02), 8, + stripe=True, + ) + + normal_concrete = make_normal_texture("N_Concrete", 0.13, 18.0) + normal_wood = make_normal_texture("N_Wood", 0.18, 7.0) + normal_metal = make_normal_texture("N_Metal", 0.035, 24.0) + normal_grass = make_normal_texture("N_Grass", 0.28, 21.0) + normal_tile = make_normal_texture("N_Tile", 0.08, 8.0) + normal_flat = make_normal_texture("N_Flat", 0.0, 1.0) + + orm_concrete = make_orm_texture("ORM_Concrete", 0.84, 0.0) + orm_rough = make_orm_texture("ORM_Rough", 0.94, 0.0, ao=0.92) + orm_wood = make_orm_texture("ORM_Wood", 0.72, 0.0) + orm_metal = make_orm_texture("ORM_Metal", 0.25, 0.88) + orm_tile = make_orm_texture("ORM_Tile", 0.32, 0.0) + orm_grass = make_orm_texture("ORM_Grass", 0.96, 0.0, ao=0.82) + orm_glass = make_orm_texture("ORM_Glass", 0.06, 0.0) + orm_ice = make_orm_texture("ORM_Ice", 0.08, 0.0) + + concrete = make_material( + "Concrete", concrete_tex, (0.72, 0.75, 0.79), 0.86, + normal_image=normal_concrete, orm_image=orm_concrete, + audio_surface=4, physics_surface=2, surface_pattern=11, + ) + orange = make_material( + "OrangeRamp", orange_tex, (1.0, 0.52, 0.12), 0.68, + normal_image=normal_wood, orm_image=orm_wood, + audio_surface=6, physics_surface=1, surface_pattern=10, + ) + brick = make_material( + "Brick", brick_tex, (0.62, 0.24, 0.13), 0.82, flags=1 | 4, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=66, physics_surface=2, surface_pattern=12, + ) + metal = make_material( + "GrindMetal", metal_tex, (0.72, 0.78, 0.84), 0.24, flags=1 | 2, + normal_image=normal_metal, orm_image=orm_metal, metallic=0.88, + audio_surface=11, physics_surface=1, + ) + cyan = make_material( + "CyanBounce", cyan_tex, (0.04, 0.94, 1.0), 0.18, + emissive=4.5, normal_image=normal_flat, orm_image=orm_glass, + emissive_image=cyan_tex, collision_enabled=False, + ) + amber = make_material( + "AmberBounce", magenta_tex, (1.0, 0.21, 0.025), 0.18, + emissive=5.0, normal_image=normal_flat, orm_image=orm_glass, + emissive_image=magenta_tex, collision_enabled=False, + ) + asphalt = make_material( + "TEST_01_Asphalt_Rough", asphalt_tex, (0.72, 0.74, 0.78), 0.94, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=2, physics_surface=2, surface_pattern=7, + ) + polished = make_material( + "TEST_02_Concrete_Polished", concrete_tex, (0.92, 0.92, 0.92), 0.28, + normal_image=normal_concrete, orm_image=orm_tile, + audio_surface=3, physics_surface=1, + ) + wood = make_material( + "TEST_03_Wood_Ramp", wood_tex, (0.95, 0.76, 0.42), 0.72, + normal_image=normal_wood, orm_image=orm_wood, + audio_surface=6, physics_surface=1, surface_pattern=10, + ) + metal_sheet = make_material( + "TEST_04_Metal_Sheet", metal_tex, (0.78, 0.84, 0.90), 0.25, + normal_image=normal_metal, orm_image=orm_metal, metallic=0.88, + audio_surface=31, physics_surface=1, + ) + tile = make_material( + "TEST_05_Ceramic_Tile", tile_tex, (0.78, 0.90, 1.0), 0.32, + normal_image=normal_tile, orm_image=orm_tile, + audio_surface=63, physics_surface=1, surface_pattern=4, + ) + grass = make_material( + "TEST_06_Grass_Slow", grass_tex, (0.58, 0.88, 0.52), 0.96, + normal_image=normal_grass, orm_image=orm_grass, + audio_surface=10, physics_surface=3, surface_pattern=7, + ) + ice = make_material( + "TEST_07_Ice_Slippery", ice_tex, (0.72, 0.92, 1.0), 0.08, + normal_image=normal_flat, orm_image=orm_ice, + audio_surface=72, physics_surface=4, + ) + stairs_material = make_material( + "TEST_Stairs", concrete_tex, (0.78, 0.80, 0.84), 0.84, + normal_image=normal_concrete, orm_image=orm_concrete, + audio_surface=53, physics_surface=8, surface_pattern=11, + ) + glass = make_material( + "TEST_Glass_Transparent", glass_tex, (0.65, 0.92, 1.0), 0.06, + normal_image=normal_flat, orm_image=orm_glass, + alpha_mode=2, audio_surface=51, physics_surface=1, + ) + fence = make_material( + "TEST_Fence_Cutout", fence_tex, (0.82, 0.86, 0.90), 0.35, + normal_image=normal_metal, orm_image=orm_metal, metallic=0.72, + alpha_mode=1, alpha_cutoff=0.5, audio_surface=68, + ) + hazard = make_material( + "TEST_Hazard_InstantBail", hazard_tex, (1.0, 0.32, 0.05), 0.82, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=4, physics_surface=9, + ) + materials = [ + concrete, orange, brick, metal, cyan, amber, asphalt, polished, + wood, metal_sheet, tile, grass, ice, stairs_material, glass, + fence, hazard, + ] + + add_box( + "PlazaFloor", (0.0, 0.0, -0.18), (48.0, 36.0, 0.36), + concrete, visual, None, bevel=0.08 + ) + # The center lane is physically partitioned by material. The surrounding + # plaza collision leaves a real hole rather than sitting underneath the + # test tiles as a competing coplanar surface. + for name, x0, x1, y0, y1 in ( + ("FloorLeft", -24.0, -3.5, -18.0, 18.0), + ("FloorRight", 3.5, 24.0, -18.0, 18.0), + ("FloorEntry", -3.5, 3.5, -18.0, -16.0), + ("FloorExit", -3.5, 3.5, 12.0, 18.0), + ): + add_collision_mesh( + name, + [(x0, y0, 0.0), (x1, y0, 0.0), + (x1, y1, 0.0), (x0, y1, 0.0)], + [(0, 1, 2, 3)], + concrete.name, + collision, + upward_surface=True, + ) + for index, material in enumerate( + (asphalt, polished, wood, metal_sheet, tile, grass, ice) + ): + y0 = -16.0 + index * 4.0 + add_surface_tile( + f"SurfaceLane_{index + 1:02d}_{material.name}", + -3.5, 3.5, y0, y0 + 4.0, + material, visual, collision, + ) + add_wedge( + "OrangeBank", -19.0, -11.0, -7.0, 5.0, 0.0, 3.8, + orange, visual, collision + ) + add_curved_ramp( + "CurvedWall", 11.0, -7.0, 5.0, 8.0, 4.5, + orange, visual, collision + ) + add_box( + "BackWall", (0.0, 16.4, 3.0), (48.0, 1.0, 6.0), + brick, visual, collision, bevel=0.14 + ) + add_box( + "CanopyRoof", (-10.0, 10.0, 4.8), (17.0, 10.0, 0.55), + metal, visual, collision, bevel=0.18 + ) + for x in (-17.5, -2.5): + add_box( + f"CanopyPost_{x}", (x, 13.2, 2.4), (0.55, 0.55, 4.8), + metal, visual, collision, bevel=0.08 + ) + add_box( + "CyanBouncePanel", (-16.8, 14.7, 2.25), (0.18, 4.5, 3.6), + cyan, visual, None, bevel=0.04 + ) + add_box( + "AmberBouncePanel", (-3.1, 14.7, 2.25), (0.18, 4.5, 3.6), + amber, visual, None, bevel=0.04 + ) + + # One welded stair shell replaces five overlapping cubes plus an + # intersecting deck, eliminating hidden internal contact planes. + add_stair_set( + "StairSet", 7.0, 6.5, 11.5, 1.05, 0.36, 5, 3.0, + stairs_material, visual, collision + ) + + # Cylindrical rail visuals; conservative box collision is authored + # independently, while the curve below is the actual grind contract. + bpy.ops.mesh.primitive_cylinder_add( + vertices=20, radius=0.075, depth=8.0, + location=(-8.0, 6.2, 0.92), rotation=(0.0, math.pi * 0.5, 0.0) + ) + rail = bpy.context.object + rail.name = "CentralRail" + move_to(rail, visual) + rail.data.materials.append(metal) + metric_uv(rail) + rail_collision = add_box( + "RailCollisionVisualHidden", (-8.0, 6.2, 0.92), (8.0, 0.15, 0.15), + metal, visual, collision + ) + # The collision helper created a visible box too; remove only that + # visual representation, preserving its independent collision duplicate. + bpy.data.objects.remove(rail_collision, do_unlink=True) + for x in (-11.4, -4.6): + add_box( + f"RailPost_{x}", (x, 6.2, 0.46), (0.13, 0.13, 0.92), + metal, visual, collision + ) + add_grind_curve( + "CentralRailGrind", + [(-12.0, 6.2, 1.01), (-4.0, 6.2, 1.01)], + grinds, + ) + + # A low manual pad makes collision and texture continuity easy to test. + add_box( + "ManualPad", (-10.0, -7.5, 0.28), (7.5, 4.0, 0.56), + brick, visual, collision, bevel=0.10 + ) + add_box( + "TransparentGlassWall", (8.0, -11.0, 2.0), (0.18, 6.0, 4.0), + glass, visual, collision, bevel=0.03 + ) + add_box( + "AlphaCutoutFence", (13.0, -11.0, 2.0), (0.12, 6.0, 4.0), + fence, visual, collision + ) + add_box( + "InstantBailHazard", (10.0, -2.0, 0.12), (5.0, 4.0, 0.24), + hazard, visual, collision, bevel=0.04 + ) + + joined = join_visuals(visual) + lightmap = bpy.data.images.new( + "LM_BakedIndirect_Showcase", width=512, height=512, alpha=True + ) + lightmap.colorspace_settings.name = "Non-Color" + add_bake_target(materials, lightmap) + + world = bpy.data.worlds.new("OW_ShowcaseWorld") + bpy.context.scene.world = world + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + background.inputs["Color"].default_value = (0.08, 0.12, 0.20, 1.0) + background.inputs["Strength"].default_value = 0.32 + + # Static lights illuminate the bake only. The runtime never imports + # Blender lights; its moving sun/moon supplies the direct component. + for name, location, color, energy in ( + ("BakeFillWarm", (10.0, -5.0, 10.0), (1.0, 0.48, 0.18), 1500.0), + ("BakeFillCool", (-14.0, 8.0, 7.0), (0.08, 0.55, 1.0), 1250.0), + ): + data = bpy.data.lights.new(name, "AREA") + data.energy = energy + data.color = color + data.shape = "DISK" + data.size = 8.0 + light = bpy.data.objects.new(name, data) + bpy.context.scene.collection.objects.link(light) + light.location = location + direction = Vector((0.0, 5.0, 0.0)) - light.location + light.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + spawn = bpy.data.objects.new("OW_SPAWN", None) + bpy.context.scene.collection.objects.link(spawn) + spawn.location = (0.0, -17.0, 0.75) + spawn.empty_display_type = "ARROWS" + spawn.empty_display_size = 1.2 + spawn["ow_heading_radians"] = 0.0 + + scene = bpy.context.scene + scene["ow_map_name"] = "blender_material_lab" + scene["ow_sky_zenith"] = (0.09, 0.34, 0.72) + scene["ow_sky_horizon"] = (0.58, 0.78, 0.98) + scene["ow_sky_nadir"] = (0.18, 0.25, 0.34) + scene["ow_cycle_seconds"] = 96.0 + scene["ow_start_hour"] = 9.0 + scene["ow_orbit_azimuth"] = 0.62 + + configure_bake(joined) + print("Starting Cycles indirect-light bake...") + bpy.ops.object.bake(type="DIFFUSE") + values = list(lightmap.pixels) + rgb = [values[index] for index in range(0, len(values), 4)] + print( + "Bake result:", + f"minimum={min(rgb):.4f}", + f"maximum={max(rgb):.4f}", + f"mean={sum(rgb) / len(rgb):.4f}", + ) + lightmap = persist_lightmap(lightmap, materials) + + BLEND_PATH.parent.mkdir(parents=True, exist_ok=True) + collision.hide_viewport = False + grinds.hide_viewport = False + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + export_scene(PACKAGE_PATH) + + +if __name__ == "__main__": + build_scene() diff --git a/tools/vendor/university/tools/blender_owned_map/create_box3d_cube_pyramid.py b/tools/vendor/university/tools/blender_owned_map/create_box3d_cube_pyramid.py new file mode 100644 index 00000000..f7a5491f --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_box3d_cube_pyramid.py @@ -0,0 +1,162 @@ +"""Generate the multi-root Box3D cube-pyramid SKATEOBJ sample.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import bpy + + +ROOT = Path(__file__).resolve().parents[2] +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon # noqa: E402 +from owned_world_material_addon.exporter import export_scene # noqa: E402 + + +OBJECT_ROOT = ROOT / "objects" +BLEND_PATH = OBJECT_ROOT / "box3d_cube_pyramid.blend" +PACKAGE_PATH = OBJECT_ROOT / "box3d_cube_pyramid.skateobj" + + +def reset_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for block in (bpy.data.meshes, bpy.data.materials, bpy.data.images): + for item in list(block): + block.remove(item) + + +def assign_owned_object( + obj: bpy.types.Object, + *, + physics_type: str, + density: float = 100.0, + friction: float = 0.65, +) -> None: + obj.data.uv_layers.new(name="UVMap") + obj.data.uv_layers.new(name="Lightmap", do_init=True) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="VISUAL") != {"FINISHED"}: + raise RuntimeError(f"unable to assign visual role to {obj.name}") + obj.select_set(True) + if bpy.ops.skate_map.assign_selected(role="COLLISION") != {"FINISHED"}: + raise RuntimeError(f"unable to assign collision role to {obj.name}") + + settings = obj.owned_world_physics + settings.physics_type = physics_type + settings.box3d_collision_shape = "BOX" + settings.box3d_density = density + settings.box3d_friction = friction + settings.box3d_restitution = 0.015 + settings.box3d_linear_damping = 0.04 + settings.box3d_angular_damping = 0.18 + settings.box3d_gravity_scale = 1.0 + settings.box3d_enable_sleep = True + settings.box3d_initially_awake = True + obj["ow_upward_surface"] = False + + +def add_box( + name: str, + location: tuple[float, float, float], + dimensions: tuple[float, float, float], + material: bpy.types.Material, + physics_type: str, + *, + density: float = 100.0, + friction: float = 0.65, +) -> bpy.types.Object: + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = dimensions + bpy.ops.object.transform_apply( + location=False, rotation=False, scale=True + ) + obj.data.materials.append(material) + assign_owned_object( + obj, + physics_type=physics_type, + density=density, + friction=friction, + ) + return obj + + +def build() -> None: + OBJECT_ROOT.mkdir(parents=True, exist_ok=True) + addon.register() + try: + reset_scene() + if bpy.ops.skate_map.prepare_scene() != {"FINISHED"}: + raise RuntimeError("unable to prepare Box3D SKATEOBJ scene") + + base_material = bpy.data.materials.new("Box3D_Base") + base_material.diffuse_color = (0.09, 0.11, 0.15, 1.0) + base_material.owned_world.roughness = 0.68 + cube_material = bpy.data.materials.new("Box3D_Cubes") + cube_material.diffuse_color = (0.96, 0.30, 0.08, 1.0) + cube_material.owned_world.roughness = 0.44 + + # The prefab pivot is OW_SPAWN at zero. The base therefore has its + # bottom exactly at placement height when spawned by the map editor. + add_box( + "Box3D_Static_Base", + (0.0, 0.0, 0.15), + (6.0, 4.0, 0.3), + base_material, + "BOX3D_STATIC", + friction=0.82, + ) + + cube_size = 1.0 + horizontal_step = 1.08 + first_center_height = 1.40 + vertical_step = 1.12 + cube_number = 0 + for row_index, count in enumerate((4, 3, 2, 1)): + height = first_center_height + row_index * vertical_step + first_x = -0.5 * (count - 1) * horizontal_step + for column in range(count): + cube_number += 1 + x = first_x + column * horizontal_step + # A tiny depth stagger avoids a perfectly constrained 2D + # arrangement while keeping the 4/3/2/1 silhouette clear. + depth = 0.025 if cube_number % 2 == 0 else -0.025 + add_box( + f"Box3D_Cube_{cube_number:02d}", + (x, depth, height), + (cube_size, cube_size, cube_size), + cube_material, + "BOX3D_DYNAMIC", + density=180.0, + friction=0.68, + ) + + scene = bpy.context.scene + scene["ow_map_name"] = "Box3D Cube Pyramid" + spawn = bpy.data.objects.get("OW_SPAWN") + if spawn is None: + raise RuntimeError("prepared scene has no OW_SPAWN pivot") + spawn.location = (0.0, 0.0, 0.0) + + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + export_scene(PACKAGE_PATH, force_rebuild=True) + print( + "SKATEOBJ_BOX3D_PYRAMID_OK " + f"roots=11 dynamic_cubes=10 {BLEND_PATH} {PACKAGE_PATH}" + ) + finally: + addon.unregister() + + +if __name__ == "__main__": + build() diff --git a/tools/vendor/university/tools/blender_owned_map/create_box3d_glass_smash.py b/tools/vendor/university/tools/blender_owned_map/create_box3d_glass_smash.py new file mode 100644 index 00000000..828f9dd7 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_box3d_glass_smash.py @@ -0,0 +1,254 @@ +"""Generate a deterministic pre-fractured Box3D glass-pane SKATEOBJ.""" + +from __future__ import annotations + +import math +from pathlib import Path +import sys + +import bpy + + +ROOT = Path(__file__).resolve().parents[2] +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon # noqa: E402 +from owned_world_material_addon.exporter import export_scene # noqa: E402 + + +OBJECT_ROOT = ROOT / "objects" +BLEND_PATH = OBJECT_ROOT / "box3d_glass_smash.blend" +PACKAGE_PATH = OBJECT_ROOT / "box3d_glass_smash.skateobj" +BREAK_GROUP = 1 + + +def make_uniform_rgba( + name: str, rgba: tuple[float, float, float, float] +) -> bpy.types.Image: + image = bpy.data.images.new(name, width=1, height=1, alpha=True) + image.colorspace_settings.name = "sRGB" + image.pixels.foreach_set(rgba) + image.update() + return image + + +def reset_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for block in (bpy.data.meshes, bpy.data.materials, bpy.data.images): + for item in list(block): + block.remove(item) + + +def assign_owned_object( + obj: bpy.types.Object, + *, + physics_type: str, + shape: str = "BOX", + density: float = 100.0, + friction: float = 0.55, +) -> None: + obj.data.uv_layers.new(name="UVMap") + obj.data.uv_layers.new(name="Lightmap", do_init=True) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="VISUAL") != {"FINISHED"}: + raise RuntimeError(f"unable to assign visual role to {obj.name}") + obj.select_set(True) + if bpy.ops.skate_map.assign_selected(role="COLLISION") != {"FINISHED"}: + raise RuntimeError(f"unable to assign collision role to {obj.name}") + settings = obj.owned_world_physics + settings.physics_type = physics_type + settings.box3d_collision_shape = shape + settings.box3d_density = density + settings.box3d_friction = friction + settings.box3d_restitution = 0.02 + settings.box3d_linear_damping = 0.08 + settings.box3d_angular_damping = 0.16 + settings.box3d_enable_sleep = True + obj["ow_upward_surface"] = False + + +def add_shard( + number: int, + triangle: tuple[ + tuple[float, float], + tuple[float, float], + tuple[float, float], + ], + material: bpy.types.Material, + hidden_edge_material: bpy.types.Material, +) -> bpy.types.Object: + center_x = sum(point[0] for point in triangle) / 3.0 + center_z = sum(point[1] for point in triangle) / 3.0 + half_depth = 0.035 + local = [ + (point[0] - center_x, point[1] - center_z) + for point in triangle + ] + vertices = [ + (x, -half_depth, z) for x, z in local + ] + [ + (x, half_depth, z) for x, z in local + ] + faces = [ + (0, 2, 1), + (3, 4, 5), + (0, 1, 4, 3), + (1, 2, 5, 4), + (2, 0, 3, 5), + ] + mesh = bpy.data.meshes.new(f"GlassShardMesh_{number:02d}") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(f"Glass_Shard_{number:02d}", mesh) + bpy.context.collection.objects.link(obj) + obj.location = (center_x, 0.0, center_z) + obj.data.materials.append(material) + obj.data.materials.append(hidden_edge_material) + # The first two polygons are the pane-facing triangles. The remaining + # three close the collision prism but must not reveal the pre-fracture + # pattern through alpha blending while the pane is intact. + for polygon in obj.data.polygons[2:]: + polygon.material_index = 1 + assign_owned_object( + obj, + physics_type="BOX3D_DYNAMIC", + shape="CONVEX_HULL", + density=42.0, + friction=0.28, + ) + settings = obj.owned_world_physics + settings.box3d_gravity_scale = 0.0 + settings.box3d_initially_awake = False + settings.box3d_break_group = BREAK_GROUP + settings.box3d_break_speed_threshold = 2.15 + settings.box3d_break_impulse_scale = 0.38 + settings.box3d_break_angular_impulse = 0.055 + settings.box3d_break_gravity_scale = 1.0 + return obj + + +def ray_to_rectangle( + angle: float, half_width: float, half_height: float +) -> tuple[float, float]: + dx = math.cos(angle) + dz = math.sin(angle) + scales = [] + if abs(dx) > 1.0e-6: + scales.append(half_width / abs(dx)) + if abs(dz) > 1.0e-6: + scales.append(half_height / abs(dz)) + scale = min(scales) + return (dx * scale, dz * scale) + + +def fracture_triangles() -> list[tuple[tuple[float, float], ...]]: + half_width = 2.7 + half_height = 1.6 + center_z = 2.05 + spoke_count = 16 + center = (0.08, center_z - 0.04) + inner: list[tuple[float, float]] = [] + outer: list[tuple[float, float]] = [] + for index in range(spoke_count): + angle = 2.0 * math.pi * index / spoke_count + radius = 0.48 + 0.12 * ((index * 7) % 5) / 4.0 + inner.append( + ( + center[0] + math.cos(angle) * radius, + center[1] + math.sin(angle) * radius, + ) + ) + edge = ray_to_rectangle(angle, half_width, half_height) + outer.append((edge[0], center_z + edge[1])) + + triangles: list[tuple[tuple[float, float], ...]] = [] + for index in range(spoke_count): + next_index = (index + 1) % spoke_count + triangles.append((center, inner[index], inner[next_index])) + if index % 2 == 0: + triangles.append( + (inner[index], outer[index], outer[next_index]) + ) + triangles.append( + (inner[index], outer[next_index], inner[next_index]) + ) + else: + triangles.append( + (inner[index], outer[index], inner[next_index]) + ) + triangles.append( + (inner[next_index], outer[index], outer[next_index]) + ) + return triangles + + +def build() -> None: + OBJECT_ROOT.mkdir(parents=True, exist_ok=True) + addon.register() + try: + reset_scene() + if bpy.ops.skate_map.prepare_scene() != {"FINISHED"}: + raise RuntimeError("unable to prepare glass SKATEOBJ scene") + + glass = bpy.data.materials.new("Breakable_Glass") + # Uniform 1x1 RGBA swatches select the imported-material path without + # adding any visible texture pattern. That path supports real opacity; + # the procedural path deliberately adds surface noise and is opaque. + glass_swatch = make_uniform_rgba( + "Glass_Uniform_RGBA", (1.0, 1.0, 1.0, 0.28) + ) + hidden_swatch = make_uniform_rgba( + "Glass_Hidden_RGBA", (1.0, 1.0, 1.0, 0.0) + ) + + glass.diffuse_color = (0.78, 0.91, 0.96, 0.17) + glass["ow_albedo_image"] = glass_swatch.name + glass.owned_world.albedo_image = glass_swatch + glass.owned_world.alpha_mode = "2" + glass.owned_world.roughness = 0.04 + glass.owned_world.metallic = 0.0 + glass.owned_world.friction = 0.25 + glass.owned_world.restitution = 0.02 + + hidden_edges = bpy.data.materials.new("Glass_Hidden_Shard_Edges") + hidden_edges.diffuse_color = (0.0, 0.0, 0.0, 0.0) + hidden_edges["ow_albedo_image"] = hidden_swatch.name + hidden_edges.owned_world.albedo_image = hidden_swatch + hidden_edges.owned_world.alpha_mode = "2" + hidden_edges.owned_world.roughness = 1.0 + hidden_edges.owned_world.metallic = 0.0 + + shard_count = 0 + for shard_count, triangle in enumerate( + fracture_triangles(), start=1 + ): + add_shard(shard_count, triangle, glass, hidden_edges) + + scene = bpy.context.scene + scene["ow_map_name"] = "Box3D Glass Smash" + spawn = bpy.data.objects.get("OW_SPAWN") + if spawn is None: + raise RuntimeError("prepared scene has no OW_SPAWN pivot") + spawn.location = (0.0, 0.0, 0.0) + + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + export_scene(PACKAGE_PATH, force_rebuild=True) + print( + "SKATEOBJ_BOX3D_GLASS_OK " + f"shards={shard_count} frame_parts=0 " + "visible_patterns=0 uniform_rgba_swatches=2 " + f"{BLEND_PATH} {PACKAGE_PATH}" + ) + finally: + addon.unregister() + + +if __name__ == "__main__": + build() diff --git a/tools/vendor/university/tools/blender_owned_map/create_feature_park.py b/tools/vendor/university/tools/blender_owned_map/create_feature_park.py new file mode 100644 index 00000000..c0b207fa --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_feature_park.py @@ -0,0 +1,893 @@ +"""Build the large Blender-authored SKATE v15 feature park. + +The park deliberately contains only features represented by the current +Blender addon and SKATE package: static visuals/collision, PBR and alpha +materials, native Skate surface channels, grinds, native-AI routes, hinged +doors, local lights, baked indirect lighting, and the day/night sky. +""" + +from __future__ import annotations + +from pathlib import Path +import math +import sys + +import bpy +from mathutils import Vector + + +TOOL_ROOT = Path(__file__).resolve().parent +ROOT = Path(__file__).resolve().parents[2] +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) +AUTO_MAP_SCRIPT_ROOT = ( + TOOL_ROOT / "agent_workflow" / "sk8-auto-map" / "scripts" +) +if str(AUTO_MAP_SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(AUTO_MAP_SCRIPT_ROOT)) + +import create_bake_showcase as base # noqa: E402 +from generate_grinds import generate_grinds # noqa: E402 +from owned_world_material_addon.exporter import export_scene # noqa: E402 + + +BLEND_PATH = ROOT / "maps" / "blender_bake_showcase.blend" +PACKAGE_PATH = ROOT / "maps" / "blender_bake_showcase.skate" +base.TEXTURE_DIR = ( + ROOT / "maps" / "blender_bake_showcase_textures" +) +MANUAL_GRIND_SPECS: list[ + tuple[str, list[tuple[float, float, float]]] +] = [] + + +def copy_uv_layer( + obj: bpy.types.Object, + source_name: str = "UVMap", + target_name: str = "Lightmap", +) -> None: + source = obj.data.uv_layers.get(source_name) + if source is None: + raise RuntimeError(f"{obj.name} has no {source_name} layer") + target = obj.data.uv_layers.get(target_name) + if target is None: + target = obj.data.uv_layers.new(name=target_name) + for index, value in enumerate(source.data): + target.data[index].uv = value.uv + + +def add_text( + text: str, + location: tuple[float, float, float], + material: bpy.types.Material, + collection: bpy.types.Collection, + *, + size: float = 1.0, +) -> bpy.types.Object: + curve = bpy.data.curves.new(f"{text}_TextCurve", type="FONT") + curve.body = text + curve.align_x = "CENTER" + curve.align_y = "CENTER" + curve.size = size + # Keep signage readable without generating tens of thousands of beveled + # font triangles. The package exporter currently crosses Blender's + # Python boundary per loop corner, so flat low-resolution lettering is + # materially faster to publish while looking identical at gameplay range. + curve.resolution_u = 1 + curve.extrude = 0.008 + curve.bevel_depth = 0.0 + obj = bpy.data.objects.new(f"SignText_{text}", curve) + collection.objects.link(obj) + obj.location = location + # Text's local +Z faces outward; rotate it onto a vertical plane facing + # the player approaching along +Y. + obj.rotation_euler = (math.pi * 0.5, 0.0, 0.0) + obj.data.materials.append(material) + bpy.ops.object.select_all(action="DESELECT") + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.convert(target="MESH") + obj = bpy.context.object + base.metric_uv(obj, metres_per_tile=1.0) + obj.select_set(False) + return obj + + +def add_zone_sign( + title: str, + center_x: float, + y: float, + material: bpy.types.Material, + text_material: bpy.types.Material, + visual: bpy.types.Collection, +) -> None: + base.add_box( + f"{title}_SignBoard", + (center_x, y, 3.7), + (16.0, 0.32, 3.0), + material, + visual, + None, + bevel=0.16, + ) + add_text( + title, + (center_x, y - 0.19, 3.72), + text_material, + visual, + size=1.05, + ) + + +def add_surface_pad( + name: str, + center: tuple[float, float], + dimensions: tuple[float, float], + material: bpy.types.Material, + visual: bpy.types.Collection, + collision: bpy.types.Collection, +) -> None: + base.add_box( + name, + (center[0], center[1], 0.11), + (dimensions[0], dimensions[1], 0.22), + material, + visual, + collision, + bevel=0.045, + ) + + +def add_rail_polyline( + name: str, + points: list[tuple[float, float, float]], + material: bpy.types.Material, + visual: bpy.types.Collection, + collision: bpy.types.Collection, + grinds: bpy.types.Collection, + *, + radius: float = 0.085, +) -> None: + for index, (start, end) in enumerate(zip(points, points[1:]), 1): + start_vector = Vector(start) + end_vector = Vector(end) + direction = end_vector - start_vector + bpy.ops.mesh.primitive_cylinder_add( + vertices=20, + radius=radius, + depth=direction.length, + location=(start_vector + end_vector) * 0.5, + ) + segment = bpy.context.object + segment.name = f"{name}_Segment_{index:02d}" + segment.rotation_mode = "QUATERNION" + segment.rotation_quaternion = direction.to_track_quat("Z", "Y") + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + base.move_to(segment, visual) + segment.data.materials.append(material) + base.metric_uv(segment, metres_per_tile=1.0) + collider = segment.copy() + collider.data = segment.data.copy() + collider.name = f"{name}_Collision_{index:02d}" + collider["ow_material"] = material.name + collider["ow_map_object_owner"] = name + collider["ow_upward_surface"] = False + collider["sk8_auto_grind_exclude"] = True + collision.objects.link(collider) + # Skate's native grind spline describes the contact line above the rail, + # not the cylinder centreline. Keeping it slightly above the collision + # surface prevents the restored physical rail from masking grind pickup. + grind_clearance = radius + 0.015 + grind_points = [ + (point[0], point[1], point[2] + grind_clearance) + for point in points + ] + MANUAL_GRIND_SPECS.append((name, grind_points)) + + +def prepare_editable_objects( + visual: bpy.types.Collection, + collision: bpy.types.Collection, + grinds: bpy.types.Collection, +) -> list[bpy.types.Object]: + """Associate each static feature's render, collision, and grind records.""" + for rail_name in ("StraightRail", "DownRail", "ArcRail"): + segments = sorted( + ( + obj + for obj in visual.objects + if obj.type == "MESH" + and obj.name.startswith(f"{rail_name}_Segment_") + ), + key=lambda obj: obj.name, + ) + if not segments: + raise RuntimeError(f"{rail_name} has no visual segments") + bpy.ops.object.select_all(action="DESELECT") + for segment in segments: + segment.select_set(True) + bpy.context.view_layer.objects.active = segments[0] + if len(segments) > 1: + bpy.ops.object.join() + rail = segments[0] + rail.name = rail_name + visual_names = { + obj.name for obj in visual.objects if obj.type == "MESH" + } + for collider in collision.objects: + if collider.type != "MESH": + continue + owner_name = str(collider.get("ow_map_object_owner", "")).strip() + if not owner_name and collider.name.startswith("COL_"): + owner_name = collider.name[4:] + if owner_name not in visual_names: + raise RuntimeError( + f"collision {collider.name!r} has no editable visual owner " + f"{owner_name!r}" + ) + collider["ow_map_object_owner"] = owner_name + + return base.prepare_shared_lightmap_uvs(visual) + + +def add_manual_grind_curves(grinds: bpy.types.Collection) -> None: + """Create fresh validation curves without the automatic edge generator.""" + if len(MANUAL_GRIND_SPECS) != 3: + raise RuntimeError( + "Feature Park must define exactly three manual grind rails" + ) + for owner_name, points in MANUAL_GRIND_SPECS: + owner = bpy.data.objects.get(owner_name) + if owner is None or owner.type != "MESH": + raise RuntimeError( + f"manual grind owner {owner_name!r} is unavailable" + ) + grind_name = f"{owner_name}_ManualGrind" + base.add_grind_curve(grind_name, points, grinds) + grind = grinds.objects.get(grind_name) + if grind is None: + raise RuntimeError( + f"manual grind {grind_name!r} was not created" + ) + world_matrix = grind.matrix_world.copy() + grind.parent = owner + grind.matrix_world = world_matrix + + +def add_npc_route( + name: str, + points: list[tuple[float, float, float]], + collection: bpy.types.Collection, + *, + skaters: int, + speed: float, + spacing: float, + closed: bool = True, +) -> None: + curve = bpy.data.curves.new(name, "CURVE") + curve.dimensions = "3D" + spline = curve.splines.new("POLY") + spline.points.add(len(points) - 1) + for target, point in zip(spline.points, points): + target.co = (*point, 1.0) + spline.use_cyclic_u = closed + obj = bpy.data.objects.new(name, curve) + obj["ow_npc_skater_count"] = skaters + obj["ow_npc_speed"] = speed + obj["ow_npc_spawn_spacing"] = spacing + collection.objects.link(obj) + + +def add_light( + name: str, + light_type: str, + location: tuple[float, float, float], + color: tuple[float, float, float], + energy: float, + influence: float, + *, + target: tuple[float, float, float] | None = None, + size: float = 0.3, + spot_size: float = 0.9, +) -> bpy.types.Object: + data = bpy.data.lights.new(f"{name}_Data", type=light_type) + data.color = color + data.energy = energy + data.cutoff_distance = influence + if light_type == "AREA": + data.shape = "DISK" + data.size = size + else: + data.shadow_soft_size = size + if light_type == "SPOT": + data.spot_size = spot_size + data.spot_blend = 0.55 + obj = bpy.data.objects.new(name, data) + bpy.context.scene.collection.objects.link(obj) + obj.location = location + if target is not None: + direction = Vector(target) - obj.location + obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + obj["ow_feature_park_zone"] = "LOCAL_LIGHT_LAB" + return obj + + +def add_hinged_door( + material: bpy.types.Material, + visual: bpy.types.Collection, +) -> bpy.types.Object: + door = base.add_box( + "FeaturePark_PhysicsDoor", + (35.0, 39.0, 1.65), + (3.2, 0.16, 3.3), + material, + visual, + None, + bevel=0.07, + ) + copy_uv_layer(door) + door["ow_physics_type"] = "HINGED_DOOR" + door["ow_door_name"] = "Feature Park Return Door" + door["ow_hinge_position"] = (33.4, 39.0, 0.0) + door["ow_hinge_axis"] = (0.0, 0.0, 1.0) + door["ow_door_min_angle_degrees"] = -105.0 + door["ow_door_max_angle_degrees"] = 105.0 + door["ow_door_initial_angle_degrees"] = 0.0 + door["ow_door_mass"] = 78.0 + door["ow_door_angular_damping"] = 2.6 + door["ow_door_return_spring_strength"] = 1.7 + door["ow_door_maximum_angular_speed"] = 1.35 + door["ow_door_contact_impulse_scale"] = 0.52 + door["ow_door_friction"] = 0.58 + door["ow_door_restitution"] = 0.015 + door["ow_door_collision_material"] = material.name + return door + + +def make_materials() -> tuple[dict[str, bpy.types.Material], list]: + concrete_tex = base.make_texture( + "T_Concrete", (0.31, 0.34, 0.38), (0.48, 0.52, 0.57), 16 + ) + orange_tex = base.make_texture( + "T_OrangePaint", (0.68, 0.12, 0.025), (0.98, 0.34, 0.04), 8, + stripe=True, + ) + brick_tex = base.make_texture( + "T_DarkBrick", (0.095, 0.045, 0.04), (0.30, 0.09, 0.055), 18 + ) + metal_tex = base.make_texture( + "T_Metal", (0.16, 0.19, 0.23), (0.48, 0.53, 0.58), 24 + ) + cyan_tex = base.make_texture( + "T_CyanEmitter", (0.015, 0.78, 1.0), (0.10, 1.0, 0.94), 4 + ) + amber_tex = base.make_texture( + "T_AmberEmitter", (1.0, 0.07, 0.012), (1.0, 0.48, 0.025), 4 + ) + white_tex = base.make_texture( + "T_SignWhite", (0.94, 0.98, 1.0), (0.94, 0.98, 1.0), 1 + ) + asphalt_tex = base.make_texture( + "T_Asphalt", (0.04, 0.045, 0.052), (0.13, 0.14, 0.15), 30 + ) + wood_tex = base.make_texture( + "T_WoodRamp", (0.24, 0.075, 0.025), (0.62, 0.30, 0.07), 10, + stripe=True, + ) + tile_tex = base.make_texture( + "T_CeramicTile", (0.10, 0.31, 0.62), (0.72, 0.90, 1.0), 8 + ) + grass_tex = base.make_texture( + "T_Grass", (0.025, 0.15, 0.035), (0.13, 0.43, 0.08), 32 + ) + ice_tex = base.make_texture( + "T_Ice", (0.30, 0.61, 0.84), (0.78, 0.95, 1.0), 6 + ) + glass_tex = base.make_texture( + "T_Glass", (0.06, 0.30, 0.48), (0.20, 0.72, 0.84), 4, + alpha=0.30, + ) + fence_tex = base.make_texture( + "T_FenceCutout", (0.05, 0.06, 0.07), (0.78, 0.82, 0.86), 8, + cutout_grid=True, + ) + hazard_tex = base.make_texture( + "T_Hazard", (0.52, 0.01, 0.008), (1.0, 0.76, 0.015), 8, + stripe=True, + ) + + normal_concrete = base.make_normal_texture("N_Concrete", 0.13, 18.0) + normal_wood = base.make_normal_texture("N_Wood", 0.18, 7.0) + normal_metal = base.make_normal_texture("N_Metal", 0.035, 24.0) + normal_grass = base.make_normal_texture("N_Grass", 0.28, 21.0) + normal_tile = base.make_normal_texture("N_Tile", 0.08, 8.0) + normal_flat = base.make_normal_texture("N_Flat", 0.0, 1.0) + + orm_concrete = base.make_orm_texture("ORM_Concrete", 0.84, 0.0) + orm_rough = base.make_orm_texture("ORM_Rough", 0.95, 0.0, ao=0.92) + orm_wood = base.make_orm_texture("ORM_Wood", 0.72, 0.0) + orm_metal = base.make_orm_texture("ORM_Metal", 0.22, 0.92) + orm_tile = base.make_orm_texture("ORM_Tile", 0.30, 0.0) + orm_grass = base.make_orm_texture("ORM_Grass", 0.97, 0.0, ao=0.82) + orm_glass = base.make_orm_texture("ORM_Glass", 0.05, 0.0) + orm_ice = base.make_orm_texture("ORM_Ice", 0.07, 0.0) + + materials = { + "concrete": base.make_material( + "Concrete", concrete_tex, (0.72, 0.75, 0.79), 0.86, + normal_image=normal_concrete, orm_image=orm_concrete, + audio_surface=4, physics_surface=2, surface_pattern=11, + ), + "orange": base.make_material( + "OrangeRamp", orange_tex, (1.0, 0.50, 0.10), 0.68, + normal_image=normal_wood, orm_image=orm_wood, + audio_surface=6, physics_surface=1, surface_pattern=10, + ), + "brick": base.make_material( + "Brick", brick_tex, (0.62, 0.22, 0.12), 0.82, flags=1 | 4, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=66, physics_surface=2, surface_pattern=12, + ), + "metal": base.make_material( + "GrindMetal", metal_tex, (0.72, 0.78, 0.84), 0.22, + flags=1 | 2, normal_image=normal_metal, orm_image=orm_metal, + metallic=0.92, audio_surface=11, physics_surface=1, + ), + "cyan": base.make_material( + "CyanEmitter", cyan_tex, (0.04, 0.94, 1.0), 0.16, + emissive=4.2, normal_image=normal_flat, orm_image=orm_glass, + emissive_image=cyan_tex, collision_enabled=False, + ), + "amber": base.make_material( + "AmberEmitter", amber_tex, (1.0, 0.22, 0.025), 0.16, + emissive=4.8, normal_image=normal_flat, orm_image=orm_glass, + emissive_image=amber_tex, collision_enabled=False, + ), + "label": base.make_material( + "SignTextEmissive", white_tex, (1.0, 1.0, 1.0), 0.28, + emissive=1.6, normal_image=normal_flat, orm_image=orm_tile, + emissive_image=white_tex, collision_enabled=False, + ), + "asphalt": base.make_material( + "TEST_01_Asphalt_Rough", asphalt_tex, (0.72, 0.74, 0.78), 0.95, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=2, physics_surface=2, surface_pattern=7, + ), + "polished": base.make_material( + "TEST_02_Concrete_Polished", concrete_tex, + (0.92, 0.92, 0.92), 0.28, + normal_image=normal_concrete, orm_image=orm_tile, + audio_surface=3, physics_surface=1, + ), + "wood": base.make_material( + "TEST_03_Wood_Ramp", wood_tex, (0.95, 0.76, 0.42), 0.72, + normal_image=normal_wood, orm_image=orm_wood, + audio_surface=6, physics_surface=1, surface_pattern=10, + ), + "metal_sheet": base.make_material( + "TEST_04_Metal_Sheet", metal_tex, (0.78, 0.84, 0.90), 0.22, + normal_image=normal_metal, orm_image=orm_metal, metallic=0.92, + audio_surface=31, physics_surface=1, + ), + "tile": base.make_material( + "TEST_05_Ceramic_Tile", tile_tex, (0.78, 0.90, 1.0), 0.30, + normal_image=normal_tile, orm_image=orm_tile, + audio_surface=63, physics_surface=1, surface_pattern=4, + ), + "grass": base.make_material( + "TEST_06_Grass_Slow", grass_tex, (0.58, 0.88, 0.52), 0.97, + normal_image=normal_grass, orm_image=orm_grass, + audio_surface=10, physics_surface=3, surface_pattern=7, + ), + "ice": base.make_material( + "TEST_07_Ice_Slippery", ice_tex, (0.72, 0.92, 1.0), 0.07, + normal_image=normal_flat, orm_image=orm_ice, + audio_surface=72, physics_surface=4, + ), + "glass": base.make_material( + "TEST_Glass_Transparent", glass_tex, (0.65, 0.92, 1.0), 0.05, + normal_image=normal_flat, orm_image=orm_glass, + alpha_mode=2, audio_surface=51, physics_surface=1, + ), + "fence": base.make_material( + "TEST_Fence_Cutout", fence_tex, (0.82, 0.86, 0.90), 0.30, + normal_image=normal_metal, orm_image=orm_metal, metallic=0.78, + alpha_mode=1, alpha_cutoff=0.5, audio_surface=68, + ), + "hazard": base.make_material( + "TEST_Hazard_InstantBail", hazard_tex, (1.0, 0.32, 0.05), 0.84, + normal_image=normal_concrete, orm_image=orm_rough, + audio_surface=4, physics_surface=9, + ), + "door": base.make_material( + "TEST_Physics_Door", wood_tex, (0.82, 0.43, 0.16), 0.58, + normal_image=normal_wood, orm_image=orm_wood, + audio_surface=6, physics_surface=1, + ), + } + materials["door"]["ow_baked_strength"] = 0.0 + return materials, list(materials.values()) + + +def build_static_park( + material: dict[str, bpy.types.Material], + visual: bpy.types.Collection, + collision: bpy.types.Collection, + grinds: bpy.types.Collection, + npc_paths: bpy.types.Collection, +) -> None: + # Keep a broad safety apron around the authored test zones and NPC routes. + # The 360 x 336 metre foundation is three times the original width and + # depth, while raised test pads sit above it to avoid coplanar collision. + base.add_box( + "FeatureParkFoundation", + (0.0, 5.0, -0.32), + (360.0, 336.0, 0.64), + material["concrete"], + visual, + collision, + bevel=0.08, + ) + + # Central material runway: seven long contact pads plus a clearly + # separated bail strip. Riding straight forward traverses every surface. + runway = ( + ("ASPHALT", material["asphalt"]), + ("POLISHED CONCRETE", material["polished"]), + ("WOOD", material["wood"]), + ("METAL", material["metal_sheet"]), + ("CERAMIC TILE", material["tile"]), + ("GRASS", material["grass"]), + ("ICE", material["ice"]), + ) + for index, (label, surface) in enumerate(runway): + y = -35.0 + index * 9.0 + add_surface_pad( + f"SurfacePad_{index + 1:02d}_{label.replace(' ', '_')}", + (0.0, y), + (11.0, 8.0), + surface, + visual, + collision, + ) + add_surface_pad( + "InstantBailHazard", + (0.0, 31.0), + (11.0, 5.0), + material["hazard"], + visual, + collision, + ) + add_zone_sign( + "SURFACE RUNWAY", 0.0, -44.0, material["brick"], + material["label"], visual, + ) + + # Left skate-geometry zone. + add_zone_sign( + "SKATE GEOMETRY", -37.0, -18.0, material["brick"], + material["label"], visual, + ) + base.add_wedge( + "WideBank", -53.0, -43.0, -10.0, 2.0, 0.0, 4.2, + material["orange"], visual, collision, + ) + base.add_curved_ramp( + "QuarterPipe", -43.0, 7.0, 17.0, 10.0, 5.2, + material["orange"], visual, collision, + ) + base.add_stair_set( + "SixStairSet", -53.0, 22.0, 30.0, 1.0, 0.32, 6, 4.0, + material["polished"], visual, collision, + ) + base.add_box( + "LongManualPad", (-30.0, -7.0, 0.32), (12.0, 5.0, 0.64), + material["brick"], visual, collision, bevel=0.10, + ) + base.add_box( + "LowLedge", (-28.0, 6.0, 0.42), (11.0, 1.2, 0.84), + material["metal_sheet"], visual, collision, bevel=0.05, + ) + base.add_box( + "HighLedge", (-31.0, 12.0, 0.72), (9.0, 1.4, 1.44), + material["polished"], visual, collision, bevel=0.06, + ) + + # Grind zone with straight, sloped, and curved/polyline rails. + add_zone_sign( + "GRIND LAB", -20.0, 26.0, material["brick"], + material["label"], visual, + ) + add_rail_polyline( + "StraightRail", + [(-31.0, 34.0, 0.62), (-17.0, 34.0, 0.62)], + material["metal"], visual, collision, grinds, + radius=0.07, + ) + add_rail_polyline( + "DownRail", + [(-31.0, 40.0, 0.95), (-24.0, 40.0, 0.72), + (-17.0, 40.0, 0.50)], + material["metal"], visual, collision, grinds, + radius=0.07, + ) + add_rail_polyline( + "ArcRail", + [ + (-30.0, 47.0, 0.62), + (-27.0, 49.0, 0.62), + (-23.0, 49.8, 0.62), + (-19.0, 49.0, 0.62), + (-16.0, 47.0, 0.62), + ], + material["metal"], visual, collision, grinds, + radius=0.07, + ) + for x, y, height in ( + (-31.0, 34.0, 0.62), (-17.0, 34.0, 0.62), + (-31.0, 40.0, 0.95), (-17.0, 40.0, 0.50), + (-30.0, 47.0, 0.62), (-16.0, 47.0, 0.62), + ): + base.add_box( + f"RailPost_{x}_{y}", (x, y, height * 0.5), + (0.16, 0.16, height), material["metal"], visual, collision, + ) + + # Retained in Blender as experimental authoring, but deliberately kept out + # of OW_NPC_PATHS so this acceptance map requests zero AI skaters. + add_npc_route( + "OuterFlowLoop", + [ + (-48.0, -43.0, 0.18), + (-15.0, -50.0, 0.18), + (24.0, -45.0, 0.18), + (50.0, -22.0, 0.18), + (50.0, 24.0, 0.18), + (23.0, 49.0, 0.18), + (-12.0, 51.0, 0.18), + (-49.0, 27.0, 0.18), + ], + npc_paths, + skaters=2, + speed=6.2, + spacing=14.0, + ) + add_npc_route( + "InnerCruiseLoop", + [ + (-17.0, -24.0, 0.18), + (16.0, -24.0, 0.18), + (29.0, -4.0, 0.18), + (18.0, 22.0, 0.18), + (-15.0, 22.0, 0.18), + (-28.0, 1.0, 0.18), + ], + npc_paths, + skaters=1, + speed=4.8, + spacing=8.0, + ) + + # Right material/rendering gallery. + add_zone_sign( + "MATERIAL GALLERY", 36.0, -18.0, material["brick"], + material["label"], visual, + ) + base.add_box( + "TransparentGlassWall", (24.0, -7.0, 2.5), (0.18, 8.0, 5.0), + material["glass"], visual, collision, bevel=0.03, + ) + base.add_box( + "AlphaCutoutFence", (30.0, -7.0, 2.5), (0.12, 8.0, 5.0), + material["fence"], visual, collision, + ) + base.add_box( + "CyanEmissiveWall", (37.0, -7.0, 2.5), (0.28, 8.0, 5.0), + material["cyan"], visual, None, bevel=0.06, + ) + base.add_box( + "AmberEmissiveWall", (44.0, -7.0, 2.5), (0.28, 8.0, 5.0), + material["amber"], visual, None, bevel=0.06, + ) + for index, (x, surface) in enumerate( + ( + (24.0, material["polished"]), + (31.0, material["metal_sheet"]), + (38.0, material["wood"]), + (45.0, material["tile"]), + ), + 1, + ): + base.add_box( + f"PBRPedestal_{index:02d}", (x, 5.0, 0.65), + (4.2, 4.2, 1.3), surface, visual, collision, bevel=0.18, + ) + bpy.ops.mesh.primitive_uv_sphere_add( + segments=32, ring_count=16, radius=1.25, location=(x, 5.0, 2.35) + ) + sphere = bpy.context.object + sphere.name = f"PBRMaterialSphere_{index:02d}" + base.move_to(sphere, visual) + sphere.data.materials.append(surface) + base.metric_uv(sphere, metres_per_tile=2.0) + + # Local light lab. Each light has a neutral pedestal and a different + # type/colour so Point, Spot, and Area behaviour is obvious at night. + add_zone_sign( + "LOCAL LIGHT LAB", 36.0, 15.0, material["brick"], + material["label"], visual, + ) + for index, x in enumerate((27.0, 36.0, 45.0), 1): + base.add_box( + f"LightPedestal_{index:02d}", (x, 23.0, 0.5), + (5.0, 5.0, 1.0), material["polished"], visual, collision, + bevel=0.12, + ) + add_light( + "FeaturePointBlue", "POINT", (27.0, 23.0, 4.0), + (0.12, 0.38, 1.0), 1050.0, 12.0, size=0.28, + ) + add_light( + "FeatureSpotRed", "SPOT", (36.0, 23.0, 6.5), + (1.0, 0.08, 0.025), 1450.0, 15.0, + target=(36.0, 23.0, 0.0), size=0.18, spot_size=0.95, + ) + add_light( + "FeatureAreaGreen", "AREA", (45.0, 23.0, 5.5), + (0.18, 1.0, 0.30), 900.0, 11.0, + target=(45.0, 23.0, 0.0), size=2.4, + ) + + # Hinged-door test bay. The opening is large and isolated enough to walk + # or skate through repeatedly without nearby collision ambiguity. + add_zone_sign( + "PHYSICS DOOR", 35.0, 31.0, material["brick"], + material["label"], visual, + ) + base.add_box( + "DoorWallLeft", (27.5, 39.0, 2.2), (11.8, 0.7, 4.4), + material["brick"], visual, collision, bevel=0.08, + ) + base.add_box( + "DoorWallRight", (42.5, 39.0, 2.2), (11.8, 0.7, 4.4), + material["brick"], visual, collision, bevel=0.08, + ) + base.add_box( + "DoorHeader", (35.0, 39.0, 4.1), (3.2, 0.7, 0.6), + material["brick"], visual, collision, bevel=0.06, + ) + + # Baked-indirect alcove: the roof and side walls make the coloured + # indirect contribution easy to compare against direct dynamic lights. + base.add_box( + "BakeAlcoveRoof", (0.0, 48.0, 5.0), (18.0, 12.0, 0.5), + material["metal"], visual, collision, bevel=0.10, + ) + for x in (-8.75, 8.75): + base.add_box( + f"BakeAlcoveWall_{x}", (x, 48.0, 2.5), (0.5, 12.0, 5.0), + material["brick"], visual, collision, bevel=0.08, + ) + base.add_box( + "BakeAlcoveCyanPanel", (-8.45, 48.0, 2.5), (0.18, 5.0, 3.8), + material["cyan"], visual, None, bevel=0.04, + ) + base.add_box( + "BakeAlcoveAmberPanel", (8.45, 48.0, 2.5), (0.18, 5.0, 3.8), + material["amber"], visual, None, bevel=0.04, + ) + add_zone_sign( + "BAKED INDIRECT", 0.0, 40.5, material["brick"], + material["label"], visual, + ) + + +def build_scene() -> None: + base.reset_scene() + MANUAL_GRIND_SPECS.clear() + visual = base.make_collection("OW_VISUAL") + collision = base.make_collection("OW_COLLISION") + grinds = base.make_collection("OW_GROUP_4_GRINDS") + base.make_collection("OW_NPC_PATHS") + npc_paths = base.make_collection("OW_NPC_PATHS_EXPERIMENTAL") + materials, material_list = make_materials() + build_static_park(materials, visual, collision, grinds, npc_paths) + + static_visuals = prepare_editable_objects(visual, collision, grinds) + grind_stats = generate_grinds() + print("Feature Park automatic grinds:", grind_stats) + add_manual_grind_curves(grinds) + print( + "Feature Park manual grinds:", + [name for name, _points in MANUAL_GRIND_SPECS], + ) + lightmap = bpy.data.images.new( + "LM_BakedIndirect_Showcase", width=1024, height=1024, alpha=True + ) + lightmap.colorspace_settings.name = "Non-Color" + base.add_bake_target(material_list, lightmap) + + world = bpy.data.worlds.new("OW_FeatureParkWorld") + bpy.context.scene.world = world + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + background.inputs["Color"].default_value = (0.055, 0.09, 0.16, 1.0) + background.inputs["Strength"].default_value = 0.28 + + # These lights provide the baked indirect contrast and remain ordinary + # runtime local lights after export. + add_light( + "BakeFillWarm", "AREA", (6.0, 47.0, 7.5), + (1.0, 0.34, 0.08), 1150.0, 16.0, + target=(0.0, 48.0, 1.0), size=5.0, + ) + add_light( + "BakeFillCool", "AREA", (-6.0, 47.0, 7.5), + (0.06, 0.42, 1.0), 1050.0, 16.0, + target=(0.0, 48.0, 1.0), size=5.0, + ) + + sun_data = bpy.data.lights.new("FeatureParkSun_Data", type="SUN") + sun_data.color = (1.0, 0.78, 0.52) + sun_data.energy = 2.7 + sun = bpy.data.objects.new("FeatureParkSun", sun_data) + bpy.context.scene.collection.objects.link(sun) + sun.rotation_euler = (math.radians(48.0), 0.0, math.radians(-34.0)) + + spawn = bpy.data.objects.new("OW_SPAWN", None) + bpy.context.scene.collection.objects.link(spawn) + spawn.location = (0.0, -50.0, 0.78) + spawn.empty_display_type = "ARROWS" + spawn.empty_display_size = 1.4 + spawn["ow_heading_radians"] = 0.0 + + scene = bpy.context.scene + scene["ow_map_name"] = "blender_feature_park" + scene["ow_sky_zenith"] = (0.055, 0.28, 0.68) + scene["ow_sky_horizon"] = (0.56, 0.78, 1.0) + scene["ow_sky_nadir"] = (0.15, 0.20, 0.30) + scene["ow_cycle_seconds"] = 150.0 + scene["ow_start_hour"] = 7.0 + scene["ow_end_hour"] = 19.0 + scene["ow_cycle_ping_pong"] = True + scene["ow_orbit_azimuth"] = 0.62 + scene["ow_twilight_zenith"] = (0.035, 0.075, 0.22) + scene["ow_twilight_horizon"] = (1.0, 0.29, 0.07) + scene["ow_twilight_nadir"] = (0.045, 0.028, 0.055) + scene["ow_night_zenith"] = (0.004, 0.010, 0.038) + scene["ow_night_horizon"] = (0.028, 0.060, 0.14) + scene["ow_night_nadir"] = (0.006, 0.010, 0.024) + scene["ow_moon_color"] = (0.38, 0.53, 0.95) + scene["ow_moon_intensity"] = 0.20 + scene["ow_day_ambient"] = 0.30 + scene["ow_night_ambient"] = 0.10 + scene["ow_sky_tint"] = (1.0, 1.0, 1.0) + + base.configure_bake(static_visuals) + print("Starting Feature Park indirect-light bake...") + bpy.ops.object.bake(type="DIFFUSE") + values = list(lightmap.pixels) + rgb = [values[index] for index in range(0, len(values), 4)] + print( + "Feature Park bake result:", + f"minimum={min(rgb):.4f}", + f"maximum={max(rgb):.4f}", + f"mean={sum(rgb) / len(rgb):.4f}", + ) + base.persist_lightmap(lightmap, material_list) + + # Dynamic door is added after joining and baking static geometry so it + # remains an independent rigid body in the exported package. + add_hinged_door(materials["door"], visual) + + BLEND_PATH.parent.mkdir(parents=True, exist_ok=True) + collision.hide_viewport = False + grinds.hide_viewport = False + npc_paths.hide_viewport = False + bpy.ops.file.pack_all() + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH), compress=True) + export_scene(PACKAGE_PATH, force_rebuild=True) + + +if __name__ == "__main__": + build_scene() diff --git a/tools/vendor/university/tools/blender_owned_map/create_map_editor_mvp.py b/tools/vendor/university/tools/blender_owned_map/create_map_editor_mvp.py new file mode 100644 index 00000000..da1179ca --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_map_editor_mvp.py @@ -0,0 +1,184 @@ +"""Create the small independently-editable map-editor validation map.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import bpy + + +ROOT = Path(__file__).resolve().parents[2] +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon # noqa: E402 +from owned_world_material_addon.exporter import export_scene # noqa: E402 + + +BLEND_PATH = ROOT / "maps" / "map_editor_mvp.blend" +PACKAGE_PATH = ROOT / "maps" / "map_editor_mvp.skate" + + +def reset_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for block in (bpy.data.meshes, bpy.data.materials, bpy.data.images): + for item in list(block): + block.remove(item) + + +def add_editable_box( + name: str, + location: tuple[float, float, float], + dimensions: tuple[float, float, float], + material: bpy.types.Material, +) -> bpy.types.Object: + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = dimensions + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(material) + if obj.data.uv_layers.get("UVMap") is None: + obj.data.uv_layers.new(name="UVMap") + if obj.data.uv_layers.get("Lightmap") is None: + obj.data.uv_layers.new(name="Lightmap", do_init=True) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="VISUAL") != {"FINISHED"}: + raise RuntimeError(f"unable to assign {name} as visual") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="COLLISION") != {"FINISHED"}: + raise RuntimeError(f"unable to assign {name} as collision") + obj["ow_upward_surface"] = False + return obj + + +def add_editable_floor( + name: str, + size: float, + material: bpy.types.Material, +) -> bpy.types.Object: + bpy.ops.mesh.primitive_plane_add(size=size, location=(0.0, 0.0, 0.0)) + obj = bpy.context.object + obj.name = name + obj.data.materials.append(material) + if obj.data.uv_layers.get("UVMap") is None: + obj.data.uv_layers.new(name="UVMap") + if obj.data.uv_layers.get("Lightmap") is None: + obj.data.uv_layers.new(name="Lightmap", do_init=True) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="VISUAL") != {"FINISHED"}: + raise RuntimeError(f"unable to assign {name} as visual") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="COLLISION") != {"FINISHED"}: + raise RuntimeError(f"unable to assign {name} as collision") + obj["ow_upward_surface"] = True + return obj + + +def add_parented_corner_grind( + parent: bpy.types.Object, + half_x: float, + half_y: float, + top_z: float, +) -> bpy.types.Object: + curve = bpy.data.curves.new(f"{parent.name}_CornerGrind", "CURVE") + curve.dimensions = "3D" + spline = curve.splines.new("POLY") + spline.points.add(3) + for point, coordinate in zip( + spline.points, + ( + (-half_x, -half_y, top_z, 1.0), + (half_x, -half_y, top_z, 1.0), + (half_x, half_y, top_z, 1.0), + (-half_x, half_y, top_z, 1.0), + ), + strict=True, + ): + point.co = coordinate + spline.use_cyclic_u = True + obj = bpy.data.objects.new(curve.name, curve) + bpy.context.scene.collection.objects.link(obj) + obj.parent = parent + obj.location = (0.0, 0.0, 0.0) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + if bpy.ops.skate_map.assign_selected(role="GRIND") != {"FINISHED"}: + raise RuntimeError(f"unable to assign {obj.name} as grind") + return obj + + +def build() -> None: + addon.register() + try: + reset_scene() + if bpy.ops.skate_map.prepare_scene() != {"FINISHED"}: + raise RuntimeError("unable to prepare owned-world scene") + + concrete = bpy.data.materials.new("EditorConcrete") + concrete.diffuse_color = (0.24, 0.29, 0.34, 1.0) + concrete.owned_world.roughness = 0.82 + orange = bpy.data.materials.new("EditorOrange") + orange.diffuse_color = (0.95, 0.26, 0.06, 1.0) + orange.owned_world.roughness = 0.58 + cyan = bpy.data.materials.new("EditorCyan") + cyan.diffuse_color = (0.04, 0.72, 0.88, 1.0) + cyan.owned_world.roughness = 0.46 + + add_editable_floor("EditorGround", 24.0, concrete) + orange_block = add_editable_box( + "MoveMe_OrangeBlock", + (-2.5, 2.5, 1.0), + (3.0, 3.0, 2.0), + orange, + ) + cyan_ledge = add_editable_box( + "MoveMe_CyanLedge", + (3.5, 1.0, 0.6), + (5.0, 1.5, 1.2), + cyan, + ) + add_parented_corner_grind( + orange_block, half_x=1.5, half_y=1.5, top_z=1.0 + ) + add_parented_corner_grind( + cyan_ledge, half_x=2.5, half_y=0.75, top_z=0.6 + ) + + spawn = bpy.data.objects.get("OW_SPAWN") + if spawn is None: + raise RuntimeError("prepared scene has no OW_SPAWN") + spawn.location = (0.0, -6.0, 0.75) + spawn["ow_heading_radians"] = 0.0 + + scene = bpy.context.scene + scene["ow_map_name"] = "map_editor_mvp" + scene["ow_sky_zenith"] = (0.04, 0.10, 0.18) + scene["ow_sky_horizon"] = (0.38, 0.58, 0.72) + scene["ow_sky_nadir"] = (0.08, 0.10, 0.13) + scene["ow_cycle_seconds"] = 120.0 + scene["ow_start_hour"] = 11.0 + scene["ow_end_hour"] = 17.0 + scene["ow_orbit_azimuth"] = 0.45 + + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + export_scene(PACKAGE_PATH, force_rebuild=True) + print(f"MAP_EDITOR_MVP_OK {BLEND_PATH} {PACKAGE_PATH}") + finally: + addon.unregister() + + +if __name__ == "__main__": + build() diff --git a/tools/vendor/university/tools/blender_owned_map/create_test_skate_object.py b/tools/vendor/university/tools/blender_owned_map/create_test_skate_object.py new file mode 100644 index 00000000..f1fb13d1 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/create_test_skate_object.py @@ -0,0 +1,108 @@ +"""Create the spawn-menu's small grindable SKATEOBJ validation prefab.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import bpy + + +ROOT = Path(__file__).resolve().parents[2] +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon # noqa: E402 +from owned_world_material_addon.exporter import export_scene # noqa: E402 + + +OBJECT_ROOT = ROOT / "objects" +BLEND_PATH = OBJECT_ROOT / "test_grind_ledge.blend" +PACKAGE_PATH = OBJECT_ROOT / "test_grind_ledge.skateobj" + + +def reset_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for block in (bpy.data.meshes, bpy.data.materials, bpy.data.images): + for item in list(block): + block.remove(item) + + +def build() -> None: + OBJECT_ROOT.mkdir(parents=True, exist_ok=True) + addon.register() + try: + reset_scene() + if bpy.ops.skate_map.prepare_scene() != {"FINISHED"}: + raise RuntimeError("unable to prepare SKATEOBJ scene") + + material = bpy.data.materials.new("SpawnLedgePaintedMetal") + material.diffuse_color = (0.10, 0.52, 0.92, 1.0) + material.owned_world.roughness = 0.42 + + bpy.ops.mesh.primitive_cube_add( + size=1.0, location=(0.0, 0.0, 0.5) + ) + ledge = bpy.context.object + ledge.name = "TestGrindLedge" + ledge.dimensions = (4.0, 1.0, 1.0) + # Bake the center offset into the mesh so the prefab root/pivot is at + # the bottom. Surface placement can then put the object fully above + # ground instead of burying its lower half. + bpy.ops.object.transform_apply( + location=True, rotation=False, scale=True + ) + ledge.data.materials.append(material) + ledge.data.uv_layers.new(name="UVMap") + ledge.data.uv_layers.new(name="Lightmap", do_init=True) + bpy.ops.object.select_all(action="DESELECT") + ledge.select_set(True) + bpy.context.view_layer.objects.active = ledge + if bpy.ops.skate_map.assign_selected(role="VISUAL") != {"FINISHED"}: + raise RuntimeError("unable to assign visual role") + ledge.select_set(True) + if bpy.ops.skate_map.assign_selected( + role="COLLISION" + ) != {"FINISHED"}: + raise RuntimeError("unable to assign collision role") + ledge["ow_upward_surface"] = False + + curve = bpy.data.curves.new("TestGrindLedge_TopGrind", "CURVE") + curve.dimensions = "3D" + spline = curve.splines.new("POLY") + spline.points.add(1) + # The ledge root is at its bottom. Put the path on the exposed long + # top corner (runtime Y=1, Z=+0.5), not through the middle of the top + # face where native grind acquisition is blocked by collision. + spline.points[0].co = (-2.0, -0.5, 1.0, 1.0) + spline.points[1].co = (2.0, -0.5, 1.0, 1.0) + grind = bpy.data.objects.new(curve.name, curve) + bpy.context.scene.collection.objects.link(grind) + grind.parent = ledge + grind.location = (0.0, 0.0, 0.0) + bpy.ops.object.select_all(action="DESELECT") + grind.select_set(True) + bpy.context.view_layer.objects.active = grind + if bpy.ops.skate_map.assign_selected(role="GRIND") != {"FINISHED"}: + raise RuntimeError("unable to assign grind role") + + scene = bpy.context.scene + scene["ow_map_name"] = "Test Grind Ledge" + spawn = bpy.data.objects.get("OW_SPAWN") + if spawn is not None: + # SKATEOBJ v2 uses OW_SPAWN as the package pivot. + spawn.location = (0.0, 0.0, 0.0) + + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + export_scene(PACKAGE_PATH, force_rebuild=True) + print(f"SKATEOBJ_TEST_OK {BLEND_PATH} {PACKAGE_PATH}") + finally: + addon.unregister() + + +if __name__ == "__main__": + build() diff --git a/tools/vendor/university/tools/blender_owned_map/export_skate.py b/tools/vendor/university/tools/blender_owned_map/export_skate.py new file mode 100644 index 00000000..7322aef8 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/export_skate.py @@ -0,0 +1,20 @@ +"""Compatibility entry point for command-line SKATE exports. + +The installable Blender addon owns the exporter implementation. Keeping this +small wrapper preserves existing background-build commands while ensuring the +GUI and command line always use the same exporter. +""" + +from pathlib import Path +import sys + + +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +from owned_world_material_addon.exporter import main + + +if __name__ == "__main__": + main() diff --git a/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/__init__.py b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/__init__.py new file mode 100644 index 00000000..f5f5501e --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/__init__.py @@ -0,0 +1,5307 @@ +"""Human-friendly Blender authoring and export tools for SKATE v15. + +This addon and exporter are original project code. They do not import, +invoke, redistribute, or depend on ArenaBuilder. +""" + +from __future__ import annotations + +from array import array +import bpy +import hashlib +import importlib +import math +from pathlib import Path +import re +import traceback +from bpy.app.handlers import persistent +from bpy.props import ( + BoolProperty, + EnumProperty, + FloatProperty, + FloatVectorProperty, + IntProperty, + PointerProperty, + StringProperty, +) +from bpy.types import ( + Material, + Object, + Operator, + Panel, + PropertyGroup, + Scene, + UIList, +) +from bpy_extras.io_utils import ExportHelper + +from . import exporter as _exporter + +# Blender may replace an installed extension while keeping its Python package +# alive. Reload the implementation module so a newly installed panel cannot +# call an older cached exporter with an incompatible function signature. +exporter = importlib.reload(_exporter) + + +bl_info = { + "name": "Owned World Authoring", + "author": "Skate 3 Custom Engine Layer contributors", + "version": (1, 15, 0), + "blender": (5, 0, 0), + "location": "3D View > Sidebar > Skate 3 Map", + "description": "Create, validate, and export Skate 3 Custom Engine maps", + "category": "Import-Export", +} + + +_SYNCING = False +BASE_COLOR_BAKE_SCHEMA = 4 +_ALPHA_SCAN_CACHE: dict[int, tuple[bool, bool]] = {} + + +_AUDIO_NAMES = """ +Undefined|Asphalt_Smooth|Asphalt_Rough|Concrete_Polished|Concrete_Rough| +Concrete_Aggregate|Wood_Ramp|Plywood|Dirt|Metal|Grass| +Metal_Solid_Round_1|Metal_Solid_Round_1_Up|Metal_Solid_Round_2| +Metal_Solid_Square_1|Metal_Solid_Square_2|Metal_Hollow_Round_1| +Metal_Hollow_Round_1_Dead|Metal_Hollow_Round_1_Dn|Metal_Hollow_Round_2| +Metal_Hollow_Round_2_Dead|Metal_Hollow_Round_2_Dn|Metal_Hollow_Round_3| +Metal_Hollow_Round_4|Metal_Hollow_Square_1|Metal_Hollow_Square_2| +Metal_Hollow_Square_3|Metal_Hollow_Square_3_Dead|Metal_Hollow_Square_4| +Metal_Hollow_1|Metal_Hollow_2|Metal_Sheet|Metal_Complex_1|Metal_Complex_2| +Metal_Complex_3|Metal_Complex_4|Metal_Complex_5|Metal_Complex_6| +Metal_Complex_7|Metal_Complex_8|Metal_Complex_Debris|Wood_1|Wood_1_Up| +Wood_2|Wood_3|Wood_3_Up|Wood_4|Plastic_1|Plastic_2|Plastic_3|Plastic_4| +Glass_Thick_Large|Glass_Thin_Small|Concrete_Curb|Concrete_Bench|Leaves| +Bush|Pottery|Paper|Cardboard|Garbage_Bag|Garbage_Spill|Bottle| +Tile_Ceramic|Marble_or_Slate|Brick_Smooth|Brick_Coarse|Manhole_Metal| +Metal_Grate_Sewer|Metal_Grate_Planter|DeepSnow|PackedSnow|Ice|Antennas| +Chandelier|Plexiglass_Small|Plexiglass_Large|Potted_Plant|Crumpled_Paper| +Cloth|Pop_Can|Paper_Cup|Wire_Cable|VolleyBall|OilDrum|DMORail|Fruit| +Plastic_Bottle|Drum_Pylon|Metal_Rail_4|Wood_5|Metal_Ramp| +Complex_Plastic_1|Max_Mappable_Surface +""" +_AUDIO_NAMES = [ + name.strip() + for name in _AUDIO_NAMES.replace("\n", "").split("|") +] +AUDIO_ITEMS = [ + (str(index), name, f"Native Skate 3 audio surface {index}") + for index, name in enumerate(_AUDIO_NAMES) +] + +PHYSICS_ITEMS = [ + ("0", "Undefined", "Default behavior"), + ("1", "Smooth", "Fast smooth riding"), + ("2", "Rough", "Medium-friction riding"), + ("3", "Slow", "Slows the rider"), + ("4", "Slippery", "Low-friction riding"), + ("5", "VerySlow", "Very slow / breadcrumb blocked"), + ("6", "Unrideable", "Cannot be ridden"), + ("7", "DoNotAlign", "Do not align board to this surface"), + ("8", "Stair", "Native stair behavior"), + ("9", "InstantBail", "Immediately forces a bail"), + ("10", "SlipperyRagdoll", "Low-friction bail behavior"), + ("11", "BouncyRagdoll", "Bouncy bail behavior"), + ("12", "Water", "Native water/swimming behavior"), + ("13", "Retail13", "Retail collision behavior 13"), +] + +PATTERN_ITEMS = [ + ("0", "None", "No native surface pattern"), + ("1", "SpiderCrack", "Cracked surface"), + ("2", "Square2x2", "2 by 2 tiles"), + ("3", "Square4x4", "4 by 4 tiles"), + ("4", "Square8x8", "8 by 8 tiles"), + ("5", "Square12x12", "12 by 12 tiles"), + ("6", "Square24x24", "24 by 24 tiles"), + ("7", "IrregularSmall", "Small irregular pattern"), + ("8", "IrregularMedium", "Medium irregular pattern"), + ("9", "IrregularLarge", "Large irregular pattern"), + ("10", "Slats", "Slatted surface"), + ("11", "Sidewalk", "Sidewalk pattern"), + ("12", "BrickTileRandomSize", "Random-size brick tile"), + ("13", "MiniTile", "Small tile pattern"), + ("14", "Special1", "Native special pattern 1"), + ("15", "Special2", "Native special pattern 2"), +] + +ALPHA_ITEMS = [ + ("0", "Opaque", "Fully opaque"), + ("1", "Cutout", "Alpha-tested leaves, grates, and fences"), + ("2", "Blend", "Alpha-blended glass and soft surfaces"), +] + +PRESENTATION_ITEMS = [ + ("STANDARD", "Standard", "Opaque or transparent world surface"), + ( + "DECAL", + "Alpha Decal", + "Non-colliding alpha surface intended for signs, stains, and overlays", + ), + ( + "VEGETATION", + "Tree / Vegetation", + "Non-colliding alpha-cutout foliage optimized for clean edges", + ), +] + +PRESET_ITEMS = [ + ("CONCRETE", "Polished Concrete", ""), + ("ROUGH_CONCRETE", "Rough Concrete", ""), + ("ASPHALT", "Rough Asphalt", ""), + ("WOOD", "Wood Ramp", ""), + ("METAL_RAIL", "Metal Rail", ""), + ("METAL_SHEET", "Metal Sheet", ""), + ("GRASS", "Grass", ""), + ("TILE", "Ceramic Tile", ""), + ("GLASS", "Glass", ""), + ("ICE", "Ice / Slippery", ""), + ("STAIRS", "Concrete Stairs", ""), + ("WATER", "Water", ""), + ("INSTANT_BAIL", "Instant Bail", ""), + ("DECAL", "Alpha Decal", ""), + ("TREE", "Tree / Vegetation", ""), +] + +PRESETS = { + "CONCRETE": (3, 1, 0, 0.72, 0.0, 0), + "ROUGH_CONCRETE": (4, 2, 0, 0.90, 0.0, 0), + "ASPHALT": (2, 2, 7, 0.94, 0.0, 0), + "WOOD": (6, 1, 10, 0.72, 0.0, 0), + "METAL_RAIL": (11, 1, 0, 0.22, 0.88, 0), + "METAL_SHEET": (31, 1, 0, 0.34, 0.78, 0), + "GRASS": (10, 3, 7, 0.96, 0.0, 0), + "TILE": (63, 1, 4, 0.38, 0.0, 0), + "GLASS": (51, 1, 0, 0.08, 0.0, 2), + "ICE": (72, 4, 0, 0.08, 0.0, 0), + "STAIRS": (53, 8, 11, 0.84, 0.0, 0), + "WATER": (0, 12, 0, 0.04, 0.0, 2), + "INSTANT_BAIL": (4, 9, 0, 0.85, 0.0, 0), + "DECAL": (3, 1, 0, 0.72, 0.0, 2), + "TREE": (55, 3, 7, 0.88, 0.0, 1), +} + +PHYSICS_TYPE_ITEMS = [ + ( + "STATIC", + "Not Simulated", + "Ordinary static world geometry; Box3D is disabled", + ), + ( + "BOX3D_STATIC", + "Box3D Static", + "A fixed Box3D collision body for dynamic owned-world objects", + ), + ( + "BOX3D_DYNAMIC", + "Box3D Dynamic", + "An independently falling and colliding Box3D rigid body", + ), + ( + "HINGED_DOOR", + "Hinged Door", + "Contact-driven rigid door constrained to one hinge axis", + ), +] + +BOX3D_SHAPE_ITEMS = [ + ("BOX", "Box", "Fast box fitted to this object's local bounds"), + ( + "SPHERE", + "Sphere", + "Sphere fitted around this object's local bounds", + ), + ( + "CONVEX_HULL", + "Convex Hull", + "Convex hull generated from this object's collision vertices", + ), +] + + +def _sync(settings: "OwnedWorldMaterialSettings") -> None: + if _SYNCING: + return + material = settings.id_data + if not isinstance(material, Material): + return + # Once an author changes any value in the material panel, preserve that + # choice on future automatic scene preparation. Materials carrying the + # True marker are still owned by the importer and may be refreshed from + # their Blender shader graph to heal stale/partial imports. + material["ow_auto_imported"] = False + material["ow_audio_surface"] = int(settings.audio_surface) + material["ow_physics_surface"] = int(settings.physics_surface) + material["ow_surface_pattern"] = int(settings.surface_pattern) + material["ow_alpha_mode"] = int(settings.alpha_mode) + material["ow_alpha_mode_authored"] = True + material["ow_alpha_cutoff"] = float(settings.alpha_cutoff) + material["ow_roughness"] = float(settings.roughness) + material["ow_metallic"] = float(settings.metallic) + material["ow_friction"] = float(settings.friction) + material["ow_restitution"] = float(settings.restitution) + material["ow_emissive"] = float(settings.emissive_strength) + material["ow_baked_strength"] = float(settings.baked_strength) + material["ow_collision_enabled"] = bool(settings.collision_enabled) + material["ow_presentation_type"] = settings.presentation_type + for prop, image in ( + ("ow_albedo_image", settings.albedo_image), + ("ow_lightmap_image", settings.lightmap_image), + ("ow_normal_image", settings.normal_image), + ("ow_orm_image", settings.orm_image), + ("ow_emissive_image", settings.emissive_image), + ): + material[prop] = image.name if image is not None else "" + + +def _updated(self, _context) -> None: + _sync(self) + + +def _sync_physics(settings: "OwnedWorldPhysicsSettings") -> None: + if _SYNCING: + return + obj = settings.id_data + if not isinstance(obj, Object): + return + obj["ow_physics_type"] = settings.physics_type + obj["ow_box3d_collision_shape"] = settings.box3d_collision_shape + obj["ow_box3d_density"] = float(settings.box3d_density) + obj["ow_box3d_friction"] = float(settings.box3d_friction) + obj["ow_box3d_restitution"] = float(settings.box3d_restitution) + obj["ow_box3d_linear_damping"] = float(settings.box3d_linear_damping) + obj["ow_box3d_angular_damping"] = float(settings.box3d_angular_damping) + obj["ow_box3d_gravity_scale"] = float(settings.box3d_gravity_scale) + obj["ow_box3d_enable_sleep"] = bool(settings.box3d_enable_sleep) + obj["ow_box3d_initially_awake"] = bool(settings.box3d_initially_awake) + obj["ow_box3d_break_group"] = int(settings.box3d_break_group) + obj["ow_box3d_break_speed_threshold"] = float( + settings.box3d_break_speed_threshold + ) + obj["ow_box3d_break_impulse_scale"] = float( + settings.box3d_break_impulse_scale + ) + obj["ow_box3d_break_angular_impulse"] = float( + settings.box3d_break_angular_impulse + ) + obj["ow_box3d_break_gravity_scale"] = float( + settings.box3d_break_gravity_scale + ) + if settings.collision_material is not None: + obj["ow_material"] = settings.collision_material.name + obj["ow_upward_surface"] = bool(settings.upward_surface) + obj["ow_hinge_axis"] = tuple(settings.hinge_axis) + obj["ow_door_min_angle_degrees"] = math.degrees( + float(settings.minimum_angle) + ) + obj["ow_door_max_angle_degrees"] = math.degrees( + float(settings.maximum_angle) + ) + obj["ow_door_initial_angle_degrees"] = math.degrees( + float(settings.initial_angle) + ) + obj["ow_door_mass"] = float(settings.mass) + obj["ow_door_angular_damping"] = float(settings.angular_damping) + obj["ow_door_return_spring_strength"] = float( + settings.return_spring_strength + ) + obj["ow_door_maximum_angular_speed"] = float( + settings.maximum_angular_speed + ) + obj["ow_door_contact_impulse_scale"] = float( + settings.contact_impulse_scale + ) + obj["ow_door_friction"] = float(settings.friction) + obj["ow_door_restitution"] = float(settings.restitution) + + +def _physics_updated(self, _context) -> None: + _sync_physics(self) + + +class OwnedWorldMaterialSettings(PropertyGroup): + presentation_type: EnumProperty( + name="Presentation Type", + items=PRESENTATION_ITEMS, + default="STANDARD", + update=_updated, + ) + audio_surface: EnumProperty( + name="Wheel / Grind Sound", + items=AUDIO_ITEMS, + default="3", + update=_updated, + ) + physics_surface: EnumProperty( + name="Physics Behavior", + items=PHYSICS_ITEMS, + default="1", + update=_updated, + ) + surface_pattern: EnumProperty( + name="Contact Pattern", + items=PATTERN_ITEMS, + default="0", + update=_updated, + ) + collision_enabled: BoolProperty( + name="Collision Enabled", + default=True, + update=_updated, + ) + alpha_mode: EnumProperty( + name="Transparency", + items=ALPHA_ITEMS, + default="0", + update=_updated, + ) + alpha_cutoff: FloatProperty( + name="Cutout Threshold", + min=0.0, + max=1.0, + default=0.5, + update=_updated, + ) + roughness: FloatProperty( + name="Fallback Roughness", + min=0.0, + max=1.0, + default=0.78, + update=_updated, + ) + metallic: FloatProperty( + name="Fallback Metallic", + min=0.0, + max=1.0, + default=0.0, + update=_updated, + ) + friction: FloatProperty( + name="Contact Friction", + description="Physical friction exported for this surface", + min=0.0, + max=2.0, + default=0.82, + update=_updated, + ) + restitution: FloatProperty( + name="Bounce", + min=0.0, + max=1.0, + default=0.0, + update=_updated, + ) + emissive_strength: FloatProperty( + name="Emissive Strength", + min=0.0, + max=100.0, + default=0.0, + update=_updated, + ) + baked_strength: FloatProperty( + name="Baked Lighting Strength", + min=0.0, + max=8.0, + default=1.0, + update=_updated, + ) + albedo_image: PointerProperty( + name="Base Color / Albedo", + type=bpy.types.Image, + update=_updated, + ) + lightmap_image: PointerProperty( + name="Baked Lighting", + type=bpy.types.Image, + update=_updated, + ) + normal_image: PointerProperty( + name="Normal Map", + type=bpy.types.Image, + update=_updated, + ) + orm_image: PointerProperty( + name="ORM (AO/Rough/Metal)", + type=bpy.types.Image, + update=_updated, + ) + emissive_image: PointerProperty( + name="Emissive Map", + type=bpy.types.Image, + update=_updated, + ) + + +class OwnedWorldPhysicsSettings(PropertyGroup): + collision_material: PointerProperty( + name="Collision Material", + description=( + "Visual material that supplies this collision object's sound " + "and physics behavior" + ), + type=Material, + update=_physics_updated, + ) + upward_surface: BoolProperty( + name="Rideable Top Surface Only", + description=( + "Require every collision triangle to face upward; useful for " + "floors and ramps" + ), + default=False, + update=_physics_updated, + ) + physics_type: EnumProperty( + name="Physics Type", + items=PHYSICS_TYPE_ITEMS, + default="STATIC", + update=_physics_updated, + ) + box3d_collision_shape: EnumProperty( + name="Collision Shape", + description="Safe collision approximation used by Box3D", + items=BOX3D_SHAPE_ITEMS, + default="BOX", + update=_physics_updated, + ) + box3d_density: FloatProperty( + name="Density", + description=( + "Mass per cubic metre; Box3D derives mass from density and shape" + ), + default=100.0, + min=1.0, + max=5000.0, + update=_physics_updated, + ) + box3d_friction: FloatProperty( + name="Friction", + default=0.55, + min=0.0, + max=2.0, + update=_physics_updated, + ) + box3d_restitution: FloatProperty( + name="Bounce", + default=0.05, + min=0.0, + max=1.0, + update=_physics_updated, + ) + box3d_linear_damping: FloatProperty( + name="Linear Damping", + description="Drag applied to straight-line motion", + default=0.05, + min=0.0, + max=20.0, + update=_physics_updated, + ) + box3d_angular_damping: FloatProperty( + name="Angular Damping", + description="Drag applied to rotation", + default=0.15, + min=0.0, + max=20.0, + update=_physics_updated, + ) + box3d_gravity_scale: FloatProperty( + name="Gravity", + description="Multiplier for world gravity; zero makes the body float", + default=1.0, + min=0.0, + max=4.0, + update=_physics_updated, + ) + box3d_enable_sleep: BoolProperty( + name="Allow Sleep", + description="Let settled dynamic bodies stop consuming solver time", + default=True, + update=_physics_updated, + ) + box3d_initially_awake: BoolProperty( + name="Start Awake", + description="Begin active instead of waiting for another body", + default=True, + update=_physics_updated, + ) + box3d_break_group: IntProperty( + name="Break Group", + description=( + "Zero disables breaking; matching nonzero groups release together" + ), + default=0, + min=0, + max=65535, + update=_physics_updated, + ) + box3d_break_speed_threshold: FloatProperty( + name="Impact Speed", + description="Minimum player impact speed needed to shatter the group", + default=2.5, + min=0.1, + max=30.0, + unit="VELOCITY", + update=_physics_updated, + ) + box3d_break_impulse_scale: FloatProperty( + name="Shatter Force", + description="How strongly the player's motion scatters released pieces", + default=0.45, + min=0.0, + max=10.0, + update=_physics_updated, + ) + box3d_break_angular_impulse: FloatProperty( + name="Shard Spin", + description="Deterministic rotational kick applied on shatter", + default=0.08, + min=0.0, + max=10.0, + update=_physics_updated, + ) + box3d_break_gravity_scale: FloatProperty( + name="Released Gravity", + description="Gravity applied after the break group is released", + default=1.0, + min=0.0, + max=4.0, + update=_physics_updated, + ) + hinge_axis: FloatVectorProperty( + name="Hinge Axis", + description="World-space hinge axis; Z is vertical in Blender", + size=3, + default=(0.0, 0.0, 1.0), + subtype="DIRECTION", + update=_physics_updated, + ) + minimum_angle: FloatProperty( + name="Minimum Angle", + subtype="ANGLE", + default=-1.8325957, + min=-3.1415927, + max=0.0, + update=_physics_updated, + ) + maximum_angle: FloatProperty( + name="Maximum Angle", + subtype="ANGLE", + default=1.8325957, + min=0.0, + max=3.1415927, + update=_physics_updated, + ) + initial_angle: FloatProperty( + name="Initial Angle", + subtype="ANGLE", + default=0.0, + min=-3.1415927, + max=3.1415927, + update=_physics_updated, + ) + mass: FloatProperty( + name="Mass", + description="Door-leaf mass in kilograms", + default=32.0, + min=0.1, + max=1000.0, + unit="MASS", + update=_physics_updated, + ) + angular_damping: FloatProperty( + name="Angular Damping", + default=2.2, + min=0.0, + max=20.0, + update=_physics_updated, + ) + return_spring_strength: FloatProperty( + name="Self-Close Strength", + description=( + "Angular spring strength that returns the leaf to its initial " + "closed angle; zero disables self-closing" + ), + default=0.0, + min=0.0, + max=40.0, + update=_physics_updated, + ) + maximum_angular_speed: FloatProperty( + name="Maximum Swing Speed", + description="Maximum hinge speed in radians per second", + default=8.0, + min=0.05, + max=30.0, + update=_physics_updated, + ) + contact_impulse_scale: FloatProperty( + name="Push Response", + description=( + "Scales physical momentum transferred from the player to the door" + ), + default=1.0, + min=0.0, + max=4.0, + update=_physics_updated, + ) + friction: FloatProperty( + name="Contact Friction", + default=0.55, + min=0.0, + max=2.0, + update=_physics_updated, + ) + restitution: FloatProperty( + name="Bounce", + default=0.02, + min=0.0, + max=1.0, + update=_physics_updated, + ) + + +def _sync_npc_path(settings: "OwnedWorldNpcPathSettings") -> None: + if _SYNCING: + return + obj = settings.id_data + if not isinstance(obj, Object): + return + obj["ow_npc_skater_count"] = int(settings.skater_count) + obj["ow_npc_speed"] = float(settings.speed) + obj["ow_npc_spawn_spacing"] = float(settings.spawn_spacing) + + +def _npc_path_updated(self, _context) -> None: + _sync_npc_path(self) + + +class OwnedWorldNpcPathSettings(PropertyGroup): + skater_count: IntProperty( + name="Skaters", + description="Number of native AI skaters assigned to this route", + default=1, + min=1, + max=32, + update=_npc_path_updated, + ) + speed: FloatProperty( + name="Route Speed", + description="Target movement speed along this route in metres/second", + default=5.5, + min=0.1, + max=30.0, + unit="VELOCITY", + update=_npc_path_updated, + ) + spawn_spacing: FloatProperty( + name="Spawn Spacing", + description="Distance between skaters when a route has a population", + default=3.0, + min=0.0, + max=100.0, + unit="LENGTH", + update=_npc_path_updated, + ) + + +def _sync_scene(settings: "OwnedWorldSceneSettings") -> None: + if _SYNCING: + return + scene = settings.id_data + if not isinstance(scene, Scene): + return + scene["ow_map_name"] = settings.map_name.strip() or "Untitled Map" + scene["ow_cycle_seconds"] = float(settings.cycle_seconds) + scene["ow_start_hour"] = float(settings.start_hour) + scene["ow_end_hour"] = float(settings.end_hour) + scene["ow_cycle_ping_pong"] = bool(settings.cycle_ping_pong) + scene["ow_dynamic_lighting_enabled_by_default"] = bool( + settings.dynamic_lighting_enabled_by_default + ) + scene["ow_orbit_azimuth"] = float(settings.orbit_azimuth) + scene["ow_sky_zenith"] = tuple(settings.sky_zenith) + scene["ow_sky_horizon"] = tuple(settings.sky_horizon) + scene["ow_sky_nadir"] = tuple(settings.sky_nadir) + scene["ow_twilight_zenith"] = tuple(settings.twilight_zenith) + scene["ow_twilight_horizon"] = tuple(settings.twilight_horizon) + scene["ow_twilight_nadir"] = tuple(settings.twilight_nadir) + scene["ow_night_zenith"] = tuple(settings.night_zenith) + scene["ow_night_horizon"] = tuple(settings.night_horizon) + scene["ow_night_nadir"] = tuple(settings.night_nadir) + scene["ow_sun_color"] = tuple(settings.sun_color) + scene["ow_moon_color"] = tuple(settings.moon_color) + scene["ow_sun_intensity"] = float(settings.sun_intensity) + scene["ow_moon_intensity"] = float(settings.moon_intensity) + scene["ow_day_ambient"] = float(settings.day_ambient) + scene["ow_night_ambient"] = float(settings.night_ambient) + scene["ow_sky_tint"] = tuple(settings.sky_tint) + spawn = bpy.data.objects.get(exporter.SPAWN_OBJECT) + if spawn is not None: + spawn["ow_heading_radians"] = float(settings.spawn_heading) + + +def _scene_updated(self, _context) -> None: + _sync_scene(self) + + +def _material_list_updated( + settings: "OwnedWorldSceneSettings", context +) -> None: + if _SYNCING or not bpy.data.materials: + return + scene = settings.id_data + if not isinstance(scene, Scene): + return + active_context = context or bpy.context + view_layer = getattr(active_context, "view_layer", None) + if view_layer is None: + return + index = max( + 0, min(int(settings.material_list_index), len(bpy.data.materials) - 1) + ) + material = bpy.data.materials[index] + matches = [ + obj + for obj in view_layer.objects + if obj.type == "MESH" + and obj.name != exporter.SPAWN_OBJECT + and any(slot.material == material for slot in obj.material_slots) + ] + for obj in view_layer.objects: + try: + obj.select_set(False) + except RuntimeError: + pass + selected: list[bpy.types.Object] = [] + for obj in matches: + if obj.hide_get() or obj.hide_select: + continue + try: + obj.select_set(True) + selected.append(obj) + except RuntimeError: + pass + if selected: + view_layer.objects.active = selected[0] + + +class OwnedWorldSceneSettings(PropertyGroup): + material_list_index: IntProperty( + name="Active Map Material", + description="Highlighted material in the map-group assignment list", + default=0, + min=0, + update=_material_list_updated, + ) + map_name: StringProperty( + name="Map Name", + description="Name shown in the in-game Maps menu", + default="My Skate Map", + update=_scene_updated, + ) + output_path: StringProperty( + name="SKATE File", + description="Destination package loaded by the game", + default="//my_skate_map.skate", + subtype="FILE_PATH", + ) + cycle_seconds: FloatProperty( + name="Day/Night Duration", + description="Seconds per cycle; zero freezes the lighting", + default=96.0, + min=0.0, + max=1800.0, + unit="TIME", + update=_scene_updated, + ) + start_hour: FloatProperty( + name="Start Hour", + default=9.0, + min=0.0, + max=24.0, + update=_scene_updated, + ) + end_hour: FloatProperty( + name="End Hour", + default=17.0, + min=0.0, + max=24.0, + update=_scene_updated, + ) + cycle_ping_pong: BoolProperty( + name="Move Between Start and End", + description="Animate back and forth without passing through night", + default=True, + update=_scene_updated, + ) + dynamic_lighting_enabled_by_default: BoolProperty( + name="Dynamic Lighting On by Default", + description=( + "Initial per-map state for sun, moon, ambient light, and world " + "shadows; players may still toggle it for the current session" + ), + default=True, + update=_scene_updated, + ) + orbit_azimuth: FloatProperty( + name="Sun Orbit Direction", + subtype="ANGLE", + default=0.62, + min=-math.pi, + max=math.pi, + update=_scene_updated, + ) + spawn_heading: FloatProperty( + name="Spawn Heading", + subtype="ANGLE", + default=0.0, + min=-math.pi, + max=math.pi, + update=_scene_updated, + ) + sky_zenith: FloatVectorProperty( + name="Sky Top", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.09, 0.34, 0.72), + update=_scene_updated, + ) + sky_horizon: FloatVectorProperty( + name="Sky Horizon", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.58, 0.78, 0.98), + update=_scene_updated, + ) + sky_nadir: FloatVectorProperty( + name="Sky Bottom", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.18, 0.25, 0.34), + update=_scene_updated, + ) + twilight_zenith: FloatVectorProperty( + name="Twilight Top", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.045, 0.10, 0.26), + update=_scene_updated, + ) + twilight_horizon: FloatVectorProperty( + name="Twilight Horizon", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(1.0, 0.32, 0.10), + update=_scene_updated, + ) + twilight_nadir: FloatVectorProperty( + name="Twilight Bottom", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.05, 0.035, 0.06), + update=_scene_updated, + ) + night_zenith: FloatVectorProperty( + name="Night Top", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.007, 0.015, 0.045), + update=_scene_updated, + ) + night_horizon: FloatVectorProperty( + name="Night Horizon", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.045, 0.085, 0.17), + update=_scene_updated, + ) + night_nadir: FloatVectorProperty( + name="Night Bottom", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.008, 0.014, 0.032), + update=_scene_updated, + ) + sun_color: FloatVectorProperty( + name="Sun Colour", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(1.0, 0.92, 0.78), + update=_scene_updated, + ) + moon_color: FloatVectorProperty( + name="Moon Colour", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(0.42, 0.56, 0.92), + update=_scene_updated, + ) + sun_intensity: FloatProperty( + name="Sun Strength", + default=1.25, + min=0.0, + max=4.0, + update=_scene_updated, + ) + moon_intensity: FloatProperty( + name="Moon Strength", + default=0.18, + min=0.0, + max=2.0, + update=_scene_updated, + ) + day_ambient: FloatProperty( + name="Day Ambient", + default=0.32, + min=0.0, + max=1.0, + update=_scene_updated, + ) + night_ambient: FloatProperty( + name="Night Ambient", + default=0.11, + min=0.0, + max=1.0, + update=_scene_updated, + ) + sky_tint: FloatVectorProperty( + name="Sky Colour", + description="RGB tint multiplied over the day, twilight, and night sky palettes", + subtype="COLOR", + size=3, + min=0.0, + max=4.0, + default=(1.0, 1.0, 1.0), + update=_scene_updated, + ) + show_advanced_lighting: BoolProperty( + name="Advanced Lighting", + description="Show twilight, night, sun and ambient map defaults", + default=False, + ) + show_sky_colours: BoolProperty( + name="Sky Colour Palettes", + description="Show the day, twilight, and night sky colour controls", + default=False, + ) + export_mode: EnumProperty( + name="Export Mode", + items=( + ( + "AUTO", + "Fast / Automatic", + "Reuse unchanged map data and rebuild only when needed", + ), + ( + "FORCE", + "Force Full Rebuild", + "Ignore the incremental cache and rebuild everything", + ), + ( + "METADATA", + "Lighting / Spawn Only", + "Patch world and spawn settings into an existing package", + ), + ), + default="AUTO", + ) + export_editable_objects: BoolProperty( + name="Editable Map Objects", + description=( + "Include existing map meshes as selectable editor objects; " + "disable this to keep the exported map fully static while " + "preserving its rendering, collision, materials, and grind paths" + ), + default=True, + ) + last_status: StringProperty( + name="Last Result", + default="Run Validate Map before your first export.", + ) + validation_details: StringProperty( + name="Validation Details", + default="", + options={"HIDDEN"}, + ) + + +class OWPHYSICS_OT_hinge_from_cursor(Operator): + bl_idname = "owphysics.hinge_from_cursor" + bl_label = "Set Hinge From 3D Cursor" + bl_description = ( + "Store the current 3D cursor as this door's world-space hinge line" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.object + if obj is None or obj.type != "MESH": + self.report({"ERROR"}, "Select a mesh object") + return {"CANCELLED"} + obj["ow_hinge_position"] = tuple(context.scene.cursor.location) + obj.owned_world_physics.physics_type = "HINGED_DOOR" + _sync_physics(obj.owned_world_physics) + self.report({"INFO"}, f"Set hinge for {obj.name}") + return {"FINISHED"} + + +def _ensure_collection( + scene: bpy.types.Scene, name: str +) -> bpy.types.Collection: + collection = bpy.data.collections.get(name) + if collection is None: + collection = bpy.data.collections.new(name) + if collection.name not in scene.collection.children: + scene.collection.children.link(collection) + return collection + + +def _link_object( + collection: bpy.types.Collection, obj: bpy.types.Object +) -> None: + if obj.name not in collection.objects: + collection.objects.link(obj) + + +def _group_collections() -> tuple[str, ...]: + return exporter.GROUP_COLLECTIONS + + +def _object_groups(obj: bpy.types.Object) -> list[str]: + names = set(_group_collections()) + return [ + collection.name + for collection in obj.users_collection + if collection.name in names + ] + + +def _move_to_group( + scene: bpy.types.Scene, obj: bpy.types.Object, group_name: str +) -> None: + target = _ensure_collection(scene, group_name) + for collection in tuple(obj.users_collection): + if collection.name in _group_collections() and collection != target: + collection.objects.unlink(obj) + _link_object(target, obj) + + +def _migrate_legacy_groups(scene: bpy.types.Scene) -> int: + legacy_visual = bpy.data.collections.get( + exporter.LEGACY_VISUAL_COLLECTION + ) + legacy_collision = bpy.data.collections.get( + exporter.LEGACY_COLLISION_COLLECTION + ) + visual_ids = { + obj.as_pointer() + for obj in legacy_visual.all_objects + } if legacy_visual is not None else set() + collision_ids = { + obj.as_pointer() + for obj in legacy_collision.all_objects + } if legacy_collision is not None else set() + migrated = 0 + for obj in scene.objects: + identity = obj.as_pointer() + if _object_groups(obj): + continue + if identity in visual_ids and identity in collision_ids: + _move_to_group( + scene, obj, exporter.PRESENTATION_COLLISION_COLLECTION + ) + elif identity in visual_ids: + _move_to_group(scene, obj, exporter.NO_COLLISION_COLLECTION) + elif identity in collision_ids: + _move_to_group(scene, obj, exporter.NO_PRESENTATION_COLLECTION) + else: + continue + migrated += 1 + for old_name, new_name in ( + (exporter.LEGACY_GRIND_COLLECTION, exporter.GRIND_COLLECTION), + (exporter.LEGACY_NPC_PATH_COLLECTION, exporter.NPC_PATH_COLLECTION), + ): + old = bpy.data.collections.get(old_name) + if old is None: + continue + for obj in tuple(old.all_objects): + if not _object_groups(obj): + _move_to_group(scene, obj, new_name) + migrated += 1 + for old_name in ( + exporter.LEGACY_VISUAL_COLLECTION, + exporter.LEGACY_COLLISION_COLLECTION, + exporter.LEGACY_GRIND_COLLECTION, + exporter.LEGACY_NPC_PATH_COLLECTION, + ): + old = bpy.data.collections.get(old_name) + if old is None: + continue + for obj in tuple(old.objects): + if _object_groups(obj): + old.objects.unlink(obj) + if not old.objects and not old.children: + bpy.data.collections.remove(old) + return migrated + + +def _selected_of_type(context, object_type: str) -> list[bpy.types.Object]: + return [ + obj for obj in context.selected_objects if obj.type == object_type + ] + + +_AUTO_HELPER_OBJECT_MARKERS = ( + "character_size", + "character size", + "player_size", + "player size", + "scale_reference", + "scale reference", +) + + +def _object_semantics(obj: bpy.types.Object) -> str: + names = [obj.name, getattr(obj.data, "name", "")] + current = obj.parent + while current is not None: + names.append(current.name) + current = current.parent + names.extend( + slot.material.name + for slot in obj.material_slots + if slot.material is not None + ) + return " ".join(names).lower() + + +def _is_auto_helper_object(obj: bpy.types.Object) -> bool: + object_identity = ( + f"{obj.name} {getattr(obj.data, 'name', '')}".lower() + ) + return any( + marker in object_identity + for marker in _AUTO_HELPER_OBJECT_MARKERS + ) + + +def _auto_visual_candidate(obj: bpy.types.Object) -> bool: + name = obj.name.lower() + lod_match = re.search(r"(^|[_. -])lod([1-9])($|[_. -])", name) + has_lod0_sibling = bool( + lod_match + and obj.parent is not None + and any( + child.type == "MESH" + and re.search( + r"(^|[_. -])lod0($|[_. -])", child.name.lower() + ) + for child in obj.parent.children + ) + ) + return ( + obj.type == "MESH" + and len(obj.data.polygons) > 0 + and not obj.hide_render + and bool(obj.get("ow_export_visual", True)) + and not _is_auto_helper_object(obj) + and not bool(obj.get("sk8_explicitly_ignored", False)) + and not has_lod0_sibling + ) + + +def _walk_node_output_images( + node, + output_socket, + images: list[bpy.types.Image], + visited: set[tuple[int, str, tuple[int, ...]]], + group_stack: tuple[bpy.types.Node, ...] = (), +) -> None: + socket_identity = str( + getattr(output_socket, "identifier", "") or output_socket.name + ) + stack_identity = tuple( + group_node.as_pointer() for group_node in group_stack + ) + identity = (node.as_pointer(), socket_identity, stack_identity) + if identity in visited: + return + visited.add(identity) + if node.type == "TEX_IMAGE" and node.image is not None: + if node.image not in images: + images.append(node.image) + return + if node.type == "GROUP" and node.node_tree is not None: + matched_output = False + for group_output in node.node_tree.nodes: + if group_output.type != "GROUP_OUTPUT": + continue + inner_socket = group_output.inputs.get(output_socket.name) + if inner_socket is None: + inner_socket = next( + ( + candidate + for candidate in group_output.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if inner_socket is None: + continue + matched_output = True + for link in inner_socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + images, + visited, + (*group_stack, node), + ) + if matched_output: + return + if node.type == "GROUP_INPUT" and group_stack: + group_node = group_stack[-1] + outer_socket = group_node.inputs.get(output_socket.name) + if outer_socket is None: + outer_socket = next( + ( + candidate + for candidate in group_node.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if outer_socket is not None: + for link in outer_socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + images, + visited, + group_stack[:-1], + ) + return + if node.type in {"MIX", "MIX_RGB"}: + # A Mix node's factor controls the blend; it is not itself the + # resulting colour/normal. Generic input-order traversal visited the + # factor first, so layered ground materials exported a height/blend + # mask as their visible albedo. Prefer the actual A/B value inputs. + if node.type == "MIX": + suffix = { + "FLOAT": "_Float", + "VECTOR": "_Vector", + "RGBA": "_Color", + }.get(str(getattr(node, "data_type", "RGBA")), "_Color") + value_inputs = [ + socket + for socket in node.inputs + if socket.name in {"A", "B"} + and str(getattr(socket, "identifier", "")).endswith(suffix) + ] + factor_inputs = [ + socket + for socket in node.inputs + if socket.name == "Factor" + and ( + str(getattr(socket, "identifier", "")).endswith("_Float") + or not str(getattr(socket, "identifier", "")) + ) + ] + else: + value_inputs = [ + socket + for socket in node.inputs + if socket.name in {"Color1", "Color2"} + ] + factor_inputs = [ + socket for socket in node.inputs if socket.name == "Fac" + ] + + mixed_images: list[bpy.types.Image] = [] + for input_socket in value_inputs: + for link in input_socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + mixed_images, + visited, + group_stack, + ) + if mixed_images: + # When the current one-texture package cannot preserve both + # layers, retain the highest-detail visible layer. Never replace + # it with a usually higher-resolution control mask. + mixed_images.sort( + key=lambda image: int(image.size[0]) * int(image.size[1]), + reverse=True, + ) + for image in mixed_images: + if image not in images: + images.append(image) + return + + defaults = [ + tuple(float(value) for value in socket.default_value) + if hasattr(socket.default_value, "__len__") + else (float(socket.default_value),) + for socket in value_inputs + ] + if len(defaults) >= 2 and defaults[0] != defaults[1]: + # If A/B are unequal constants, the factor really is the output + # data (for example an opacity mask between zero and one). + for input_socket in factor_inputs: + for link in input_socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + images, + visited, + group_stack, + ) + return + for input_socket in node.inputs: + for link in input_socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + images, + visited, + group_stack, + ) + + +def _node_images_upstream( + socket, + group_stack: tuple[bpy.types.Node, ...] = (), +) -> list[bpy.types.Image]: + images: list[bpy.types.Image] = [] + visited: set[tuple[int, str, tuple[int, ...]]] = set() + for link in socket.links: + _walk_node_output_images( + link.from_node, + link.from_socket, + images, + visited, + group_stack, + ) + return images + + +def _walk_effective_input( + node, + output_socket, + group_stack: tuple[bpy.types.Node, ...], + visited: set[tuple[int, str, tuple[int, ...]]], +) -> bool: + socket_identity = str( + getattr(output_socket, "identifier", "") or output_socket.name + ) + stack_identity = tuple( + group_node.as_pointer() for group_node in group_stack + ) + identity = (node.as_pointer(), socket_identity, stack_identity) + if identity in visited: + return False + visited.add(identity) + if node.type == "GROUP_INPUT" and group_stack: + group_node = group_stack[-1] + outer_socket = group_node.inputs.get(output_socket.name) + if outer_socket is None: + outer_socket = next( + ( + candidate + for candidate in group_node.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if outer_socket is None: + return False + return any( + _walk_effective_input( + link.from_node, + link.from_socket, + group_stack[:-1], + visited, + ) + for link in outer_socket.links + ) + # A concrete node outside a Group Input is authored graph input, whether + # it is an image, a procedural operation, or a constant RGB/value node. + return True + + +def _socket_has_effective_input( + node: bpy.types.Node | None, + socket_name: str, + group_stack: tuple[bpy.types.Node, ...] = (), +) -> bool: + if node is None: + return False + socket = node.inputs.get(socket_name) + if socket is None: + return False + return any( + _walk_effective_input( + link.from_node, + link.from_socket, + group_stack, + set(), + ) + for link in socket.links + ) + + +def _nodes_recursive(node_tree, visited_trees: set[int] | None = None): + if node_tree is None: + return + if visited_trees is None: + visited_trees = set() + identity = node_tree.as_pointer() + if identity in visited_trees: + return + visited_trees.add(identity) + for node in node_tree.nodes: + yield node + if node.type == "GROUP" and node.node_tree is not None: + yield from _nodes_recursive(node.node_tree, visited_trees) + + +def _walk_shader_context( + node, + output_socket, + group_stack: tuple[bpy.types.Node, ...], + visited: set[tuple[int, str, tuple[int, ...]]], + shader_type: str, +) -> tuple[bpy.types.Node, tuple[bpy.types.Node, ...]] | None: + socket_identity = str( + getattr(output_socket, "identifier", "") or output_socket.name + ) + stack_identity = tuple( + group_node.as_pointer() for group_node in group_stack + ) + identity = (node.as_pointer(), socket_identity, stack_identity) + if identity in visited: + return None + visited.add(identity) + if node.type == shader_type: + return node, group_stack + if node.type == "GROUP" and node.node_tree is not None: + for group_output in node.node_tree.nodes: + if group_output.type != "GROUP_OUTPUT": + continue + inner_socket = group_output.inputs.get(output_socket.name) + if inner_socket is None: + inner_socket = next( + ( + candidate + for candidate in group_output.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if inner_socket is None: + continue + for link in inner_socket.links: + result = _walk_shader_context( + link.from_node, + link.from_socket, + (*group_stack, node), + visited, + shader_type, + ) + if result is not None: + return result + return None + if node.type == "GROUP_INPUT" and group_stack: + group_node = group_stack[-1] + outer_socket = group_node.inputs.get(output_socket.name) + if outer_socket is None: + outer_socket = next( + ( + candidate + for candidate in group_node.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if outer_socket is not None: + for link in outer_socket.links: + result = _walk_shader_context( + link.from_node, + link.from_socket, + group_stack[:-1], + visited, + shader_type, + ) + if result is not None: + return result + return None + for input_socket in node.inputs: + for link in input_socket.links: + result = _walk_shader_context( + link.from_node, + link.from_socket, + group_stack, + visited, + shader_type, + ) + if result is not None: + return result + return None + + +def _principled_context( + material: bpy.types.Material, +) -> tuple[bpy.types.Node | None, tuple[bpy.types.Node, ...]]: + tree = material.node_tree + if tree is None: + return None, () + outputs = sorted( + ( + node + for node in tree.nodes + if node.type == "OUTPUT_MATERIAL" + and bool(getattr(node, "is_active_output", True)) + ), + key=lambda node: node.name.casefold(), + ) + for output in outputs: + surface = output.inputs.get("Surface") + if surface is None: + continue + for link in surface.links: + result = _walk_shader_context( + link.from_node, + link.from_socket, + (), + set(), + "BSDF_PRINCIPLED", + ) + if result is not None: + return result + return None, () + + +def _glass_context( + material: bpy.types.Material, +) -> tuple[bpy.types.Node | None, tuple[bpy.types.Node, ...]]: + tree = material.node_tree + if tree is None: + return None, () + outputs = sorted( + ( + node + for node in tree.nodes + if node.type == "OUTPUT_MATERIAL" + and bool(getattr(node, "is_active_output", True)) + ), + key=lambda node: node.name.casefold(), + ) + for output in outputs: + surface = output.inputs.get("Surface") + if surface is None: + continue + for link in surface.links: + result = _walk_shader_context( + link.from_node, + link.from_socket, + (), + set(), + "BSDF_GLASS", + ) + if result is not None: + return result + return None, () + + +def _principled_node( + material: bpy.types.Material, +) -> bpy.types.Node | None: + return _principled_context(material)[0] + + +def _socket_image( + node: bpy.types.Node | None, + socket_name: str, + group_stack: tuple[bpy.types.Node, ...] = (), +) -> bpy.types.Image | None: + if node is None: + return None + socket = node.inputs.get(socket_name) + if socket is None: + return None + images = _node_images_upstream(socket, group_stack) + return images[0] if images else None + + +def _resolve_effective_source( + node, + output_socket, + group_stack: tuple[bpy.types.Node, ...], + visited: set[tuple[int, str, tuple[int, ...]]] | None = None, +): + """Resolve Group Input/Output plumbing to the concrete upstream node.""" + if visited is None: + visited = set() + socket_identity = str( + getattr(output_socket, "identifier", "") or output_socket.name + ) + stack_identity = tuple( + group_node.as_pointer() for group_node in group_stack + ) + identity = (node.as_pointer(), socket_identity, stack_identity) + if identity in visited: + return None + visited.add(identity) + if node.type == "GROUP_INPUT" and group_stack: + group_node = group_stack[-1] + outer_socket = group_node.inputs.get(output_socket.name) + if outer_socket is None: + outer_socket = next( + ( + candidate + for candidate in group_node.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if outer_socket is not None: + for link in outer_socket.links: + resolved = _resolve_effective_source( + link.from_node, + link.from_socket, + group_stack[:-1], + visited, + ) + if resolved is not None: + return resolved + return None + if node.type == "GROUP" and node.node_tree is not None: + for group_output in node.node_tree.nodes: + if group_output.type != "GROUP_OUTPUT": + continue + inner_socket = group_output.inputs.get(output_socket.name) + if inner_socket is None: + inner_socket = next( + ( + candidate + for candidate in group_output.inputs + if str(getattr(candidate, "identifier", "")) + == socket_identity + ), + None, + ) + if inner_socket is None: + continue + for link in inner_socket.links: + resolved = _resolve_effective_source( + link.from_node, + link.from_socket, + (*group_stack, node), + visited, + ) + if resolved is not None: + return resolved + return None + return node, output_socket, group_stack + + +def _effective_input_source( + node: bpy.types.Node | None, + socket_name: str, + group_stack: tuple[bpy.types.Node, ...] = (), +): + if node is None: + return None + socket = node.inputs.get(socket_name) + if socket is None: + return None + for link in socket.links: + resolved = _resolve_effective_source( + link.from_node, link.from_socket, group_stack + ) + if resolved is not None: + return resolved + return None + + +def _mix_value_and_factor_inputs(node): + if node.type == "MIX": + suffix = { + "FLOAT": "_Float", + "VECTOR": "_Vector", + "RGBA": "_Color", + }.get(str(getattr(node, "data_type", "RGBA")), "_Color") + values = [ + socket + for socket in node.inputs + if socket.name in {"A", "B"} + and str(getattr(socket, "identifier", "")).endswith(suffix) + ] + factors = [ + socket + for socket in node.inputs + if socket.name == "Factor" + and ( + str(getattr(socket, "identifier", "")).endswith("_Float") + or not str(getattr(socket, "identifier", "")) + ) + ] + elif node.type == "MIX_RGB": + values = [ + socket + for socket in node.inputs + if socket.name in {"Color1", "Color2"} + ] + factors = [ + socket for socket in node.inputs if socket.name == "Fac" + ] + else: + return [], [] + return values, factors + + +def _resolved_socket_source( + socket: bpy.types.NodeSocket, + group_stack: tuple[bpy.types.Node, ...], +): + for link in socket.links: + resolved = _resolve_effective_source( + link.from_node, link.from_socket, group_stack + ) + if resolved is not None: + return resolved + return None + + +def _direct_image_from_socket( + socket: bpy.types.NodeSocket, + group_stack: tuple[bpy.types.Node, ...], +) -> tuple[bpy.types.Image | None, bpy.types.NodeSocket | None]: + """Resolve only plumbing, not arbitrary shader operations.""" + resolved = _resolved_socket_source(socket, group_stack) + visited: set[int] = set() + while resolved is not None: + node, output, source_stack = resolved + identity = node.as_pointer() + if identity in visited: + return None, None + visited.add(identity) + if node.type == "TEX_IMAGE" and node.image is not None: + return node.image, output + if node.type == "REROUTE": + input_socket = node.inputs[0] if node.inputs else None + if input_socket is None: + return None, None + resolved = _resolved_socket_source(input_socket, source_stack) + continue + return None, None + return None, None + + +def _direct_input_image( + node: bpy.types.Node | None, + socket_name: str, + group_stack: tuple[bpy.types.Node, ...] = (), +) -> bpy.types.Image | None: + if node is None: + return None + socket = node.inputs.get(socket_name) + if socket is None: + return None + return _direct_image_from_socket(socket, group_stack)[0] + + +def _direct_factor_image( + socket: bpy.types.NodeSocket, + group_stack: tuple[bpy.types.Node, ...], +) -> tuple[bpy.types.Image | None, int]: + resolved = _resolved_socket_source(socket, group_stack) + if resolved is None: + return None, 0 + node, output, source_stack = resolved + if node.type == "TEX_IMAGE" and node.image is not None: + output_name = str(output.name).casefold() + return node.image, 4 if output_name == "alpha" else 0 + if node.type not in {"SEPARATE_COLOR", "SEPARATE_XYZ"}: + return None, -1 + input_socket = node.inputs.get("Color") + if input_socket is None: + input_socket = node.inputs.get("Vector") + if input_socket is None and node.inputs: + input_socket = node.inputs[0] + if input_socket is None: + return None, -1 + image, _image_output = _direct_image_from_socket( + input_socket, source_stack + ) + if image is None: + return None, -1 + output_name = str(output.name).casefold() + channel = { + "red": 1, + "r": 1, + "x": 1, + "green": 2, + "g": 2, + "y": 2, + "blue": 3, + "b": 3, + "z": 3, + "alpha": 4, + }.get(output_name, 0) + return image, channel + + +def _ordinary_mix_layers( + shader: bpy.types.Node | None, + group_stack: tuple[bpy.types.Node, ...], +) -> tuple[ + bpy.types.Image | None, + bpy.types.Image | None, + bpy.types.Image | None, + float, + int, +]: + """Return A, B, factor image, scalar factor and factor channel.""" + source = _effective_input_source( + shader, "Base Color", group_stack + ) + if source is None: + return None, None, None, 0.0, 0 + node, _output, source_stack = source + if node.type not in {"MIX", "MIX_RGB"}: + return None, None, None, 0.0, 0 + if ( + str(getattr(node, "blend_type", "MIX")) != "MIX" + ): + return None, None, None, 0.0, 0 + value_inputs, factor_inputs = _mix_value_and_factor_inputs(node) + if len(value_inputs) < 2 or not factor_inputs: + return None, None, None, 0.0, 0 + layers = [ + _direct_image_from_socket(socket, source_stack)[0] + for socket in value_inputs[:2] + ] + if layers[0] is None or layers[1] is None: + return None, None, None, 0.0, 0 + factor_socket = factor_inputs[0] + factor = max( + 0.0, min(1.0, float(factor_socket.default_value)) + ) + factor_channel = 0 + if factor_socket.links: + factor_image, factor_channel = _direct_factor_image( + factor_socket, source_stack + ) + if factor_channel < 0: + return None, None, None, 0.0, 0 + else: + factor_image = None + return ( + layers[0], + layers[1], + factor_image, + factor, + factor_channel, + ) + + +def _image_address_mode( + material: bpy.types.Material, + image: bpy.types.Image | None, +) -> int: + if image is None: + return 0 + extensions = { + str(getattr(node, "extension", "REPEAT")) + for node in _nodes_recursive(material.node_tree) + if node.type == "TEX_IMAGE" and node.image == image + } + if len(extensions) != 1: + return 0 + return { + "REPEAT": 0, + "EXTEND": 1, + "CLIP": 2, + "MIRROR": 3, + }.get(extensions.pop(), 0) + + +def _image_uv_map_name( + material: bpy.types.Material, + image: bpy.types.Image | None, +) -> str: + """Resolve an unambiguous UV Map node feeding an Image Texture.""" + if image is None or material.node_tree is None: + return "" + image_nodes = [ + node + for node in _nodes_recursive(material.node_tree) + if node.type == "TEX_IMAGE" and node.image == image + ] + if len(image_nodes) != 1: + return "" + pending = list(image_nodes[0].inputs.get("Vector").links) + visited: set[int] = set() + while pending: + link = pending.pop(0) + node = link.from_node + identity = node.as_pointer() + if identity in visited: + continue + visited.add(identity) + if node.type == "UVMAP": + return str(node.uv_map) + for input_socket in node.inputs: + pending.extend(input_socket.links) + return "" + + +def _image_uv_transform( + material: bpy.types.Material, + image: bpy.types.Image | None, +) -> tuple[float, float, float, float, float]: + """Read the common UV -> Mapping -> Image affine transform.""" + identity = (1.0, 1.0, 0.0, 0.0, 0.0) + if image is None or material.node_tree is None: + return identity + image_nodes = [ + node + for node in _nodes_recursive(material.node_tree) + if node.type == "TEX_IMAGE" and node.image == image + ] + if len(image_nodes) != 1: + return identity + vector_socket = image_nodes[0].inputs.get("Vector") + if vector_socket is None or len(vector_socket.links) != 1: + return identity + mapping = vector_socket.links[0].from_node + if ( + mapping.type != "MAPPING" + or str(getattr(mapping, "vector_type", "POINT")) != "POINT" + ): + return identity + location = mapping.inputs.get("Location") + rotation = mapping.inputs.get("Rotation") + scale = mapping.inputs.get("Scale") + if ( + location is None + or rotation is None + or scale is None + or location.links + or rotation.links + or scale.links + ): + return identity + return ( + float(scale.default_value[0]), + float(scale.default_value[1]), + float(rotation.default_value[2]), + float(location.default_value[0]), + float(location.default_value[1]), + ) + + +def _socket_float( + node: bpy.types.Node | None, socket_name: str, fallback: float +) -> float: + if node is None: + return fallback + socket = node.inputs.get(socket_name) + return float(socket.default_value) if socket is not None else fallback + + +def _socket_color( + node: bpy.types.Node | None, + socket_name: str, + fallback: tuple[float, float, float], +) -> tuple[float, float, float]: + if node is None: + return fallback + socket = node.inputs.get(socket_name) + if socket is None: + return fallback + return tuple(float(value) for value in socket.default_value[:3]) + + +def _image_alpha_usage( + image: bpy.types.Image | None, +) -> tuple[bool, bool]: + """Return (uses_alpha, blend_instead_of_cutout) for imported colour art.""" + if ( + image is None + or image.channels < 4 + or image.size[0] <= 0 + or image.size[1] <= 0 + or _is_generated_material_map(image) + ): + return False, False + identity = image.as_pointer() + cached = _ALPHA_SCAN_CACHE.get(identity) + if cached is not None: + return cached + + pixel_count = int(image.size[0]) * int(image.size[1]) + if pixel_count <= 0: + return False, False + values = array("f", [0.0]) * (pixel_count * 4) + image.pixels.foreach_get(values) + sample_stride = max(1, pixel_count // 16384) + sample_indices = range(0, pixel_count, sample_stride) + alpha_min = 1.0 + alpha_max = 0.0 + alpha_sum = 0.0 + low_alpha = 0 + opaque_alpha = 0 + sample_count = 0 + for pixel_index in sample_indices: + alpha = max( + 0.0, min(1.0, float(values[pixel_index * 4 + 3])) + ) + alpha_min = min(alpha_min, alpha) + alpha_max = max(alpha_max, alpha) + alpha_sum += alpha + low_alpha += int(alpha <= 0.5) + opaque_alpha += int(alpha >= 0.995) + sample_count += 1 + alpha_mean = alpha_sum / sample_count + low_fraction = low_alpha / sample_count + opaque_fraction = opaque_alpha / sample_count + # Ignore tiny compression/noise excursions in otherwise opaque images. + # A real cutout has a measurable transparent field, or is globally + # translucent. Sampling is bounded so Auto Prepare remains practical on + # very large imported texture sets. + uses_alpha = ( + alpha_min < 0.5 + and (low_fraction >= 0.002 or alpha_mean < 0.98) + ) or alpha_mean < 0.90 + # Art with almost no fully opaque pixels is a soft transparency mask, not + # a binary cutout. A 0.5 clip would erase faint stains and leave harsh + # edges on decals. Real cutout cards such as leaves normally contain a + # meaningful opaque field and remain on the cheaper cutout path. + blend = uses_alpha and ( + alpha_max < 0.5 or opaque_fraction < 0.005 + ) + result = uses_alpha, blend + _ALPHA_SCAN_CACHE[identity] = result + return result + + +def _remove_legacy_inferred_alpha(node_tree) -> int: + if node_tree is None: + return 0 + removed = 0 + visited: set[int] = set() + + def visit(tree) -> None: + nonlocal removed + identity = tree.as_pointer() + if identity in visited: + return + visited.add(identity) + for node in list(tree.nodes): + if ( + node.type == "TEX_IMAGE" + and node.name == "SKATE Imported Albedo Alpha" + ): + tree.nodes.remove(node) + removed += 1 + elif node.type == "GROUP" and node.node_tree is not None: + visit(node.node_tree) + + visit(node_tree) + return removed + + +def _infer_contact_preset(material: bpy.types.Material) -> str: + name = material.name.lower() + rules = ( + (("water",), "WATER"), + (("glass", "window"), "GLASS"), + (("ice",), "ICE"), + (("grass", "dirt", "soil", "mud"), "GRASS"), + (("stair", "step"), "STAIRS"), + (("wood", "plywood", "timber"), "WOOD"), + (("rail", "coping", "pipe", "metal"), "METAL_RAIL"), + (("asphalt", "road", "tarmac"), "ASPHALT"), + (("tile", "marble", "slate"), "TILE"), + (("concrete", "cement", "brick", "stone"), "CONCRETE"), + ) + for markers, preset in rules: + if any(marker in name for marker in markers): + return preset + return "CONCRETE" + + +def _is_generated_material_map( + image: bpy.types.Image | None, + kind: str | None = None, +) -> bool: + if image is None: + return False + generated_kind = str(image.get("ow_generated_map", "")).upper() + return bool(generated_kind) and ( + kind is None or generated_kind == kind.upper() + ) + + +def _generated_image( + material: bpy.types.Material, + kind: str, + pixels: list[float], + size: int, + *, + non_color: bool, +) -> bpy.types.Image: + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", material.name)[:48] + name = f"OW_AUTO_{kind}_{safe_name}" + image = bpy.data.images.get(name) + if image is None: + image = bpy.data.images.new(name, width=size, height=size, alpha=True) + elif tuple(image.size) != (size, size): + image.scale(size, size) + image["ow_generated_map"] = kind + image["ow_generated_for"] = material.name + # Material image slots are stored as names in custom properties, which do + # not count as Blender datablock users. Keep generated maps alive even + # when no shader node references them directly. + image.use_fake_user = True + if non_color: + try: + image.colorspace_settings.name = "Non-Color" + except TypeError: + pass + image.pixels.foreach_set(pixels) + image.update() + # Generated-image pixel buffers are transient unless packed. Without this + # step Blender reloads the datablock at its default black generated colour, + # causing the exporter to reject a normal or ORM map after reopening the + # .blend. + image.pack() + return image + + +def _generate_material_maps( + material: bpy.types.Material, + *, + roughness: float, + metallic: float, + emissive_color: tuple[float, float, float], + emissive_strength: float, + normal_source: bpy.types.Image | None, + orm_source: bpy.types.Image | None, + emissive_source: bpy.types.Image | None, +) -> tuple[ + bpy.types.Image | None, + bpy.types.Image | None, + bpy.types.Image | None, +]: + # A missing normal map means the Principled shader's geometric normal. + # Older builds invented a 32x32 micro-normal for every scalar-only + # material. Besides changing Blender-authored presentation, that forced + # the runtime down its derivative/TBN reconstruction path for every + # visible pixel. Preserve only a normal map that the author supplied. + normal_image = ( + None + if _is_generated_material_map(normal_source, "NORMAL") + else normal_source + ) + + orm_image = ( + None + if _is_generated_material_map(orm_source, "ORM") + else orm_source + ) + # Roughness already has a scalar field in the SKATE material record and + # missing ORM naturally means AO=1 and metallic=0 in the renderer. Only + # synthesize ORM when a scalar metallic value cannot otherwise be carried + # by the current package format. + if orm_image is None and metallic > 1.0e-4: + orm_image = _generated_image( + material, + "ORM", + [1.0, roughness, metallic, 1.0] * 16, + 4, + non_color=True, + ) + + emissive_image = ( + None + if _is_generated_material_map(emissive_source, "EMISSIVE") + else emissive_source + ) + if emissive_image is None and emissive_strength > 0.0: + maximum = max(1.0, *emissive_color) + color = tuple(max(0.0, value / maximum) for value in emissive_color) + emissive_image = _generated_image( + material, + "EMISSIVE", + [*color, 1.0] * 16, + 4, + non_color=False, + ) + return normal_image, orm_image, emissive_image + + +def _auto_configure_material( + material: bpy.types.Material, + *, + force: bool = False, + generate_maps: bool = False, +) -> bool: + global _SYNCING + authored_keys = ( + "ow_albedo_image", + "ow_secondary_albedo_image", + "ow_blend_mask_image", + "ow_lightmap_image", + "ow_normal_image", + "ow_orm_image", + "ow_emissive_image", + "ow_audio_surface", + "ow_physics_surface", + ) + preserve_authored = ( + not force + and any(key in material for key in authored_keys) + and not bool(material.get("ow_auto_imported", False)) + ) + + # Older importer builds invented transparency whenever an albedo happened + # to contain non-opaque alpha. That alpha often stores tint, roughness, or + # layer masks rather than Blender surface opacity. Remove those exact + # generated links and let the authored shader graph be authoritative. + legacy_alpha_removed = _remove_legacy_inferred_alpha(material.node_tree) + shader, shader_group_stack = _principled_context(material) + is_glass = False + color_socket_name = "Base Color" + if shader is None: + glass_shader, glass_stack = _glass_context(material) + if glass_shader is not None: + shader = glass_shader + shader_group_stack = glass_stack + is_glass = True + color_socket_name = "Color" + + if is_glass: + # SK8 does not reproduce Blender refraction, but a physical Glass BSDF + # is unambiguously transparent. Convert it deterministically to a + # compact neutral blend material instead of exporting the missing-map + # purple fallback. Use the connected glass colour graph only to choose + # the tint; opacity is a stable basic-glass approximation. + glass_color_socket = shader.inputs.get(color_socket_name) + if glass_color_socket is not None and not glass_color_socket.links: + glass_tint = tuple( + max(0.0, min(1.0, float(value))) + for value in glass_color_socket.default_value[:3] + ) + else: + # A linked physical-glass colour graph commonly contains custom + # engine parameters, cubemaps, or object tints that have no SK8 + # equivalent. Sampling one arbitrary upstream image can turn the + # whole window magenta. Use a stable neutral window tint for the + # explicitly accepted basic-glass fallback. + glass_tint = (0.72, 0.82, 0.90) + direct_base_image = _generated_image( + material, + "GLASS", + [*glass_tint, 0.32] * 16, + 4, + non_color=False, + ) + mix_primary = None + mix_secondary = None + mix_mask = None + mix_factor = 0.0 + mix_mask_channel = 0 + requires_base_bake = False + else: + direct_base_image = _direct_input_image( + shader, color_socket_name, shader_group_stack + ) + ( + mix_primary, + mix_secondary, + mix_mask, + mix_factor, + mix_mask_channel, + ) = _ordinary_mix_layers(shader, shader_group_stack) + base_socket = ( + shader.inputs.get(color_socket_name) + if shader is not None + else None + ) + requires_base_bake = bool( + base_socket is not None + and base_socket.links + and direct_base_image is None + and mix_primary is None + ) + base_socket = ( + shader.inputs.get(color_socket_name) + if shader is not None + else None + ) + baked_base_image = bpy.data.images.get( + str(material.get("ow_baked_base_color_image", "")) + ) + base_image = ( + baked_base_image + if requires_base_bake and baked_base_image is not None + else direct_base_image + ) + if base_image is None: + # This image is only a visible emergency fallback until the complex + # graph is baked. It must not make the graph look representable. + base_image = _socket_image( + shader, color_socket_name, shader_group_stack + ) + if mix_primary is not None and mix_secondary is not None: + base_image = mix_primary + normal_source = _effective_input_source( + shader, "Normal", shader_group_stack + ) + normal_image = None + if normal_source is not None: + normal_node, _normal_output, normal_stack = normal_source + if normal_node.type == "NORMAL_MAP": + normal_image = _socket_image( + normal_node, "Color", normal_stack + ) + elif normal_node.type == "TEX_IMAGE": + # A directly connected image is a conventional tangent normal. + normal_image = normal_node.image + # Emission routed through a complex imported shader group cannot be + # represented by a single source image without evaluating its masks. + # Keep direct Principled emission support, but do not mistake the group's + # ordinary albedo input for self-illumination. + emissive_image = _socket_image(shader, "Emission Color") + orm_image = None + lightmap_image = None + if preserve_authored: + base_image = ( + bpy.data.images.get(str(material.get("ow_albedo_image", ""))) + or base_image + ) + lightmap_image = bpy.data.images.get( + str(material.get("ow_lightmap_image", "")) + ) + normal_image = ( + bpy.data.images.get(str(material.get("ow_normal_image", ""))) + or normal_image + ) + orm_image = ( + bpy.data.images.get(str(material.get("ow_orm_image", ""))) + or orm_image + ) + emissive_image = ( + bpy.data.images.get(str(material.get("ow_emissive_image", ""))) + or emissive_image + ) + display_color = _socket_color( + shader, color_socket_name, tuple(material.diffuse_color[:3]) + ) + # Blender ignores a socket's default colour while an albedo is linked. + # Exporting the usual grey default as an extra tint would darken the + # texture a second time in SK8. + if base_image is not None and base_socket is not None and base_socket.links: + display_color = (1.0, 1.0, 1.0) + if is_glass: + display_color = (1.0, 1.0, 1.0) + roughness = max( + 0.0, min(1.0, _socket_float(shader, "Roughness", 0.78)) + ) + metallic = max( + 0.0, min(1.0, _socket_float(shader, "Metallic", 0.0)) + ) + emissive_color = _socket_color( + shader, "Emission Color", (1.0, 1.0, 1.0) + ) + emission_socket = ( + shader.inputs.get("Emission Color") + if shader is not None + else None + ) + # A linked socket is not by itself exportable emission. Imported node + # graphs often route an unsupported procedural/mask chain into the + # Principled input while leaving its fallback colour black. Only a + # deterministically resolved image or a non-black scalar colour can + # contribute to the SKATE material. + emission_linked = _socket_has_effective_input( + shader, "Emission Color", shader_group_stack + ) + emission_authored = bool( + emissive_image is not None + or (not emission_linked and max(emissive_color) > 1.0e-6) + ) + # Blender 4/5 Principled materials default to black Emission Color but an + # Emission Strength of 1. Black times one is still non-emissive. Copying + # the scalar alone made every automatically adopted material self-lit. + emissive = ( + max(0.0, _socket_float(shader, "Emission Strength", 0.0)) + if emission_authored + else 0.0 + ) + if generate_maps: + normal_image, orm_image, emissive_image = _generate_material_maps( + material, + roughness=roughness, + metallic=metallic, + emissive_color=emissive_color, + emissive_strength=emissive, + normal_source=normal_image, + orm_source=orm_image, + emissive_source=emissive_image, + ) + + alpha_socket = shader.inputs.get("Alpha") if shader is not None else None + alpha_image = _socket_image(shader, "Alpha", shader_group_stack) + alpha_value = ( + float(alpha_socket.default_value) if alpha_socket is not None else 1.0 + ) + inferred_alpha, inferred_blend = _image_alpha_usage(alpha_image) + if is_glass: + alpha_mode = 2 + elif alpha_image is not None and inferred_alpha: + alpha_mode = 2 if inferred_blend else 1 + elif alpha_value < 0.999: + alpha_mode = 2 + else: + alpha_mode = 0 + + changed = bool(legacy_alpha_removed) + for property_name, image in ( + ("ow_albedo_image", base_image), + ("ow_secondary_albedo_image", mix_secondary), + ("ow_blend_mask_image", mix_mask), + ("ow_lightmap_image", lightmap_image), + ("ow_normal_image", normal_image), + ("ow_orm_image", orm_image), + ("ow_emissive_image", emissive_image), + ): + image_name = image.name if image is not None else "" + existing_name = str(material.get(property_name, "")) + existing_image = bpy.data.images.get(existing_name) + if ( + preserve_authored + and existing_name + and not _is_generated_material_map(existing_image) + ): + continue + if str(material.get(property_name, "")) != image_name: + material[property_name] = image_name + changed = True + compatibility_values = { + "ow_requires_base_color_bake": ( + requires_base_bake + and not ( + bool( + material.get( + "ow_base_color_bake_failed", False + ) + ) + and int( + material.get( + "ow_base_color_bake_failed_schema", 0 + ) + ) + == BASE_COLOR_BAKE_SCHEMA + ) + and ( + not preserve_authored + or bool( + material.get( + "ow_baked_base_color_image", "" + ) + ) + or bool( + material.get( + "ow_base_color_bake_failed", False + ) + ) + ) + ), + "ow_blend_factor": mix_factor, + "ow_blend_mask_channel": mix_mask_channel, + "ow_albedo_address_mode": _image_address_mode( + material, base_image + ), + "ow_secondary_address_mode": _image_address_mode( + material, mix_secondary + ), + "ow_blend_mask_address_mode": _image_address_mode( + material, mix_mask + ), + "ow_uv_map": _image_uv_map_name(material, base_image), + "ow_secondary_uv_map": _image_uv_map_name( + material, mix_secondary + ), + "ow_blend_mask_uv_map": _image_uv_map_name( + material, mix_mask + ), + "ow_uv_transform": _image_uv_transform( + material, base_image + ), + "ow_secondary_uv_transform": _image_uv_transform( + material, mix_secondary + ), + "ow_blend_mask_uv_transform": _image_uv_transform( + material, mix_mask + ), + "ow_cull_mode": ( + 2 if bool(material.use_backface_culling) else 1 + ), + "ow_uses_object_color": ( + False if is_glass else _material_uses_object_color(material) + ), + } + for property_name, value in compatibility_values.items(): + existing = material.get(property_name) + if ( + hasattr(value, "__len__") + and not isinstance(value, str) + and existing is not None + ): + differs = tuple(existing) != tuple(value) + else: + differs = existing != value + if differs: + material[property_name] = value + changed = True + if not preserve_authored: + material["ow_display_color"] = display_color + material["ow_flags"] = 1 + # Scene adoption only translates Blender-authored PBR data. Gameplay + # semantics are explicit SKATE choices made by the author or map + # agent, never inferred from material names. + material["ow_audio_surface"] = 0 + material["ow_physics_surface"] = 0 + material["ow_surface_pattern"] = 0 + material["ow_alpha_mode"] = alpha_mode + material["ow_alpha_cutoff"] = 0.5 + material["ow_roughness"] = roughness + material["ow_metallic"] = metallic + material["ow_friction"] = 0.82 + material["ow_restitution"] = 0.0 + material["ow_emissive"] = emissive + material["ow_baked_strength"] = 1.0 if lightmap_image else 0.0 + material["ow_collision_enabled"] = True + material["ow_presentation_type"] = "STANDARD" + changed = True + elif not bool(material.get("ow_alpha_mode_authored", False)): + # Collision/contact choices may be agent-authored while presentation + # opacity is still importer-owned. Keep those choices and heal stale + # alpha metadata from the current Blender shader. + if int(material.get("ow_alpha_mode", 0)) != alpha_mode: + material["ow_alpha_mode"] = alpha_mode + changed = True + if is_glass: + # This is part of the technical Glass-BSDF translation, not a gameplay + # material choice. Clear stale importer/object tint even when contact + # semantics were authored later, otherwise the neutral fallback can + # still be multiplied back to the original magenta/green placeholder. + if tuple(material.get("ow_display_color", ())) != (1.0, 1.0, 1.0): + material["ow_display_color"] = (1.0, 1.0, 1.0) + changed = True + if int(material.get("ow_alpha_mode", 0)) != 2: + material["ow_alpha_mode"] = 2 + changed = True + if not preserve_authored: + material["ow_auto_imported"] = True + was_syncing = _SYNCING + _SYNCING = True + try: + _hydrate_material(material) + finally: + _SYNCING = was_syncing + return changed + + +def _material_uses_object_color(material: bpy.types.Material) -> bool: + return any( + node.type == "OBJECT_INFO" + and any( + output.name == "Color" and output.links + for output in node.outputs + ) + for node in _nodes_recursive(material.node_tree) + ) + + +def _base_color_bake_size( + material: bpy.types.Material, +) -> tuple[int, int]: + shader, stack = _principled_context(material) + if shader is None: + return 512, 512 + socket = shader.inputs.get("Base Color") + images = _node_images_upstream(socket, stack) if socket else [] + widths = [int(image.size[0]) for image in images if image.size[0]] + heights = [int(image.size[1]) for image in images if image.size[1]] + return ( + max(64, min(1024, max(widths, default=512))), + max(64, min(1024, max(heights, default=512))), + ) + + +def _base_color_bake_image( + material: bpy.types.Material, +) -> bpy.types.Image: + digest = hashlib.sha1( + material.name_full.encode("utf-8") + ).hexdigest()[:10] + clean_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", material.name)[:48] + name = f"OW_BAKED_BASE_{clean_name}_{digest}" + width, height = _base_color_bake_size(material) + image = bpy.data.images.get(name) + if image is not None and tuple(image.size) != (width, height): + bpy.data.images.remove(image) + image = None + if image is None: + image = bpy.data.images.new( + name, + width=width, + height=height, + alpha=True, + float_buffer=False, + ) + image.use_fake_user = True + image["ow_generated_base_color_bake"] = True + return image + + +def _bake_full_uv_domain_for_material( + scene: bpy.types.Scene, + material: bpy.types.Material, + image: bpy.types.Image, +) -> None: + mesh = bpy.data.meshes.new("OW_TEMP_BASE_COLOR_BAKE_MESH") + mesh.from_pydata( + ( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + ), + (), + ((0, 1, 2, 3),), + ) + mesh.update() + mesh.materials.append(material) + uv_names = {"UVMap"} + uv_names.update( + str(node.uv_map) + for node in _nodes_recursive(material.node_tree) + if node.type == "UVMAP" and str(node.uv_map) + ) + for uv_name in sorted(uv_names, key=str.casefold): + uv = mesh.uv_layers.new(name=uv_name) + for loop in mesh.loops: + point = mesh.vertices[loop.vertex_index].co + uv.data[loop.index].uv = (point.x, point.y) + uv.active_render = uv_name == "UVMap" + mesh.uv_layers.active = mesh.uv_layers["UVMap"] + # A Vertex Color node returns black when its named layer is absent. The + # full-domain bake mesh is synthetic, so provide neutral white layers for + # every explicitly referenced colour attribute. This keeps the flattened + # texture neutral; object-level colour remains a lightweight export + # variant, while imported meshes do not turn black merely because the + # temporary bake plane lacked their TINT layer. + color_attribute_names = { + str(node.layer_name) + for node in _nodes_recursive(material.node_tree) + if node.type == "VERTEX_COLOR" and str(node.layer_name) + } + for attribute_name in sorted(color_attribute_names, key=str.casefold): + attribute = mesh.color_attributes.new( + name=attribute_name, + type="FLOAT_COLOR", + domain="CORNER", + ) + for item in attribute.data: + item.color = (1.0, 1.0, 1.0, 1.0) + + temporary = bpy.data.objects.new( + "OW_TEMP_BASE_COLOR_BAKE", mesh + ) + temporary.name = "OW_TEMP_BASE_COLOR_BAKE" + scene.collection.objects.link(temporary) + target = None + emission_tree = None + emission_color = None + emission_strength = None + emission_color_default = None + emission_strength_default = None + emission_color_links = [] + emission_strength_links = [] + alpha_bake_image = None + try: + # Evaluate every point in the complete texture domain. Baking one + # source object's UV islands leaves untouched black texels that other + # objects sharing the material can sample. + temporary.color = (1.0, 1.0, 1.0, 1.0) + temporary.hide_viewport = False + temporary.hide_render = False + temporary.hide_select = False + for visibility_name in ( + "visible_camera", + "visible_diffuse", + "visible_glossy", + "visible_shadow", + "visible_transmission", + "visible_volume_scatter", + ): + if hasattr(temporary, visibility_name): + setattr(temporary, visibility_name, True) + target = material.node_tree.nodes.new("ShaderNodeTexImage") + target.name = "OW_TEMP_BASE_COLOR_BAKE_TARGET" + target.image = image + material.node_tree.nodes.active = target + # Cycles DIFFUSE/COLOR bakes the post-BSDF diffuse lobe. Metallic + # surfaces therefore become black even though their Blender Base + # Color is valid. Route the resolved Base Color into the Principled + # emission input temporarily and bake EMIT instead. This captures the + # colour graph itself, independent of metallic, roughness, lighting, + # or the viewing environment. + shader, _stack = _principled_context(material) + if shader is None: + raise RuntimeError("material has no Principled Base Color") + base_color = shader.inputs.get("Base Color") + emission_color = ( + shader.inputs.get("Emission Color") + or shader.inputs.get("Emission") + ) + emission_strength = shader.inputs.get("Emission Strength") + if ( + base_color is None + or emission_color is None + or emission_strength is None + ): + raise RuntimeError( + "material Principled shader has no bakeable emission inputs" + ) + emission_tree = shader.id_data + emission_color_default = tuple(emission_color.default_value) + emission_strength_default = float(emission_strength.default_value) + emission_color_links = [ + (link.from_socket, link.to_socket) + for link in emission_color.links + ] + emission_strength_links = [ + (link.from_socket, link.to_socket) + for link in emission_strength.links + ] + for link in list(emission_color.links): + emission_tree.links.remove(link) + for link in list(emission_strength.links): + emission_tree.links.remove(link) + if base_color.links: + emission_tree.links.new( + base_color.links[0].from_socket, emission_color + ) + else: + emission_color.default_value = base_color.default_value + emission_strength.default_value = 1.0 + bpy.ops.object.select_all(action="DESELECT") + temporary.hide_render = False + temporary.hide_set(False) + temporary.select_set(True) + bpy.context.view_layer.objects.active = temporary + bpy.ops.object.bake( + type="EMIT", + use_clear=True, + margin=8, + ) + # An EMIT bake writes opaque alpha even when the evaluated Principled + # Alpha input contains the cutout/blend mask. Bake that scalar graph + # separately, then put it back into the generated albedo. The runtime + # can now discard or blend the same pixels Blender does instead of + # showing their hidden black RGB values as rectangular decals. + alpha_socket = shader.inputs.get("Alpha") + if ( + int(material.get("ow_alpha_mode", 0)) != 0 + and alpha_socket is not None + ): + alpha_bake_image = bpy.data.images.new( + "OW_TEMP_BASE_ALPHA_BAKE", + width=int(image.size[0]), + height=int(image.size[1]), + alpha=True, + float_buffer=False, + ) + try: + alpha_bake_image.colorspace_settings.name = "Non-Color" + except (AttributeError, TypeError): + pass + target.image = alpha_bake_image + material.node_tree.nodes.active = target + for link in list(emission_color.links): + emission_tree.links.remove(link) + if alpha_socket.links: + emission_tree.links.new( + alpha_socket.links[0].from_socket, + emission_color, + ) + else: + alpha_value = max( + 0.0, + min(1.0, float(alpha_socket.default_value)), + ) + emission_color.default_value = ( + alpha_value, + alpha_value, + alpha_value, + 1.0, + ) + bpy.ops.object.bake( + type="EMIT", + use_clear=True, + margin=8, + ) + base_pixels = array("f", [0.0]) * len(image.pixels) + alpha_pixels = array("f", [0.0]) * len( + alpha_bake_image.pixels + ) + image.pixels.foreach_get(base_pixels) + alpha_bake_image.pixels.foreach_get(alpha_pixels) + for index in range(0, len(base_pixels), 4): + base_pixels[index + 3] = max( + 0.0, + min(1.0, alpha_pixels[index]), + ) + image.pixels.foreach_set(base_pixels) + image.update() + image["ow_bake_alpha_preserved"] = True + target.image = image + else: + image["ow_bake_alpha_preserved"] = False + + pixels = list(image.pixels[:]) + if not any( + pixels[index] > 1.0e-6 + for index in range(3, len(pixels), 4) + ): + raise RuntimeError( + "Blender bake produced no covered texels" + ) + image["ow_bake_has_coverage"] = True + image["ow_base_color_bake_schema"] = BASE_COLOR_BAKE_SCHEMA + image["skate3_allow_blank_rgb"] = not any( + pixels[index] + or pixels[index + 1] + or pixels[index + 2] + for index in range(0, len(pixels), 4) + ) + image.pack() + image.use_fake_user = True + finally: + if emission_tree is not None: + if emission_color is not None: + for link in list(emission_color.links): + emission_tree.links.remove(link) + if emission_color_default is not None: + emission_color.default_value = emission_color_default + for from_socket, to_socket in emission_color_links: + emission_tree.links.new(from_socket, to_socket) + if emission_strength is not None: + for link in list(emission_strength.links): + emission_tree.links.remove(link) + if emission_strength_default is not None: + emission_strength.default_value = ( + emission_strength_default + ) + for from_socket, to_socket in emission_strength_links: + emission_tree.links.new(from_socket, to_socket) + if target is not None and target.id_data is not None: + material.node_tree.nodes.remove(target) + if alpha_bake_image is not None: + bpy.data.images.remove(alpha_bake_image) + bpy.data.objects.remove(temporary, do_unlink=True) + if mesh.users == 0: + bpy.data.meshes.remove(mesh) + + +def _bake_complex_base_colors( + context, + visual_objects: list[bpy.types.Object], +) -> tuple[int, list[str]]: + by_material: dict[int, tuple[bpy.types.Material, list[bpy.types.Object]]] = {} + for obj in visual_objects: + for slot in obj.material_slots: + material = slot.material + if material is None or not bool( + material.get("ow_requires_base_color_bake", False) + ): + continue + identity = material.as_pointer() + if identity not in by_material: + by_material[identity] = (material, []) + by_material[identity][1].append(obj) + pending = [] + for material, objects in by_material.values(): + existing = bpy.data.images.get( + str(material.get("ow_baked_base_color_image", "")) + ) + pixels: list[float] = [] + schema_matches = bool( + existing is not None + and int( + existing.get("ow_base_color_bake_schema", 0) + ) + == BASE_COLOR_BAKE_SCHEMA + ) + covered = bool( + schema_matches + and existing.get("ow_bake_has_coverage", False) + ) + if schema_matches and not covered: + pixels = list(existing.pixels[:]) + covered = any( + pixels[index] > 1.0e-6 + for index in range(3, len(pixels), 4) + ) + if covered: + existing["ow_bake_has_coverage"] = True + if pixels: + existing["skate3_allow_blank_rgb"] = not any( + pixels[index] + or pixels[index + 1] + or pixels[index + 2] + for index in range(0, len(pixels), 4) + ) + else: + pending.append((material, objects)) + if not pending: + return 0, [] + + scene = context.scene + previous_engine = scene.render.engine + previous_samples = int(scene.cycles.samples) + previous_denoising = bool(scene.cycles.use_denoising) + previous_margin = int(scene.render.bake.margin) + previous_active = context.view_layer.objects.active + previous_selected = list(context.selected_objects) + baked = 0 + warnings: list[str] = [] + try: + scene.render.engine = "CYCLES" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + scene.render.bake.margin = 8 + for index, (material, objects) in enumerate(pending, start=1): + image = _base_color_bake_image(material) + try: + _bake_full_uv_domain_for_material( + scene, material, image + ) + except Exception as error: + shader, stack = _principled_context(material) + fallback = _socket_image( + shader, "Base Color", stack + ) + material["ow_baked_base_color_image"] = "" + material["ow_albedo_image"] = ( + fallback.name if fallback is not None else "" + ) + material["ow_secondary_albedo_image"] = "" + material["ow_blend_mask_image"] = "" + material["ow_blend_factor"] = 0.0 + material["ow_uv_map"] = _image_uv_map_name( + material, fallback + ) + material["ow_uv_transform"] = _image_uv_transform( + material, fallback + ) + material["ow_albedo_address_mode"] = _image_address_mode( + material, fallback + ) + material["ow_base_color_bake_failed"] = True + material["ow_base_color_bake_failed_schema"] = ( + BASE_COLOR_BAKE_SCHEMA + ) + material["ow_requires_base_color_bake"] = False + warnings.append( + f"{material.name}: Blender could not bake its complex " + f"Base Color across the full UV domain ({error}); " + "using the best direct image " + "fallback." + ) + continue + material["ow_baked_base_color_image"] = image.name + material["ow_base_color_bake_failed"] = False + material["ow_base_color_bake_failed_schema"] = ( + BASE_COLOR_BAKE_SCHEMA + ) + material["ow_albedo_image"] = image.name + material["ow_secondary_albedo_image"] = "" + material["ow_blend_mask_image"] = "" + material["ow_blend_factor"] = 0.0 + material["ow_uv_map"] = "UVMap" + material["ow_uv_transform"] = (1.0, 1.0, 0.0, 0.0, 0.0) + material["ow_albedo_address_mode"] = 0 + material["ow_display_color"] = (1.0, 1.0, 1.0) + material["ow_uses_object_color"] = _material_uses_object_color( + material + ) + baked += 1 + if index == len(pending) or index % 10 == 0: + print( + f"SKATE material bake: {index}/{len(pending)} " + f"{material.name}", + flush=True, + ) + finally: + scene.render.engine = previous_engine + scene.cycles.samples = previous_samples + scene.cycles.use_denoising = previous_denoising + scene.render.bake.margin = previous_margin + bpy.ops.object.select_all(action="DESELECT") + for obj in previous_selected: + if obj.name in context.view_layer.objects: + obj.select_set(True) + if ( + previous_active is not None + and previous_active.name in context.view_layer.objects + ): + context.view_layer.objects.active = previous_active + return baked, warnings + + +def _new_uv_layer( + mesh: bpy.types.Mesh, name: str +) -> bpy.types.MeshUVLoopLayer: + # Blender 5.1 can successfully add a UV layer while returning None from + # MeshUVLoopLayers.new() for some imported meshes. Resolve the created + # layer from the collection instead of trusting that return value. + mesh.uv_layers.new(name=name) + layer = mesh.uv_layers.get(name) + if layer is None: + raise RuntimeError( + f"{mesh.name}: Blender could not create the {name!r} UV layer" + ) + # Some imported Blender 5.1 meshes expose unset custom-data storage as a + # large negative float sentinel instead of zero. Explicit initialization + # keeps newly generated UVs deterministic and prevents those sentinels + # from reaching texture sampling or the package analyzer. + for item in layer.data: + item.uv = (0.0, 0.0) + return layer + + +def _sanitize_uv_layer(layer: bpy.types.MeshUVLoopLayer) -> int: + repaired = 0 + for item in layer.data: + u = float(item.uv[0]) + v = float(item.uv[1]) + clean_u = u if math.isfinite(u) and abs(u) <= 1.0e6 else 0.0 + clean_v = v if math.isfinite(v) and abs(v) <= 1.0e6 else 0.0 + if clean_u != u or clean_v != v: + item.uv = (clean_u, clean_v) + repaired += int(clean_u != u) + int(clean_v != v) + return repaired + + +def _project_box_uv( + mesh: bpy.types.Mesh, + layer: bpy.types.MeshUVLoopLayer, +) -> None: + """Create deterministic, normalized box UVs without context operators.""" + if not mesh.vertices: + return + minimum = [ + min(float(vertex.co[axis]) for vertex in mesh.vertices) + for axis in range(3) + ] + maximum = [ + max(float(vertex.co[axis]) for vertex in mesh.vertices) + for axis in range(3) + ] + extent = [ + max(maximum[axis] - minimum[axis], 1.0e-6) + for axis in range(3) + ] + for polygon in mesh.polygons: + dominant = max( + range(3), key=lambda axis: abs(float(polygon.normal[axis])) + ) + uv_axes = ( + (0, 1) if dominant == 2 + else ((0, 2) if dominant == 1 else (1, 2)) + ) + for loop_index in polygon.loop_indices: + vertex = mesh.vertices[ + mesh.loops[loop_index].vertex_index + ].co + layer.data[loop_index].uv = ( + (float(vertex[uv_axes[0]]) - minimum[uv_axes[0]]) + / extent[uv_axes[0]], + (float(vertex[uv_axes[1]]) - minimum[uv_axes[1]]) + / extent[uv_axes[1]], + ) + mesh["ow_generated_box_uv"] = True + + +def _ensure_auto_uv_layers( + mesh: bpy.types.Mesh, +) -> tuple[int, bool, int]: + created = 0 + repaired = 0 + source = mesh.uv_layers.get("UVMap") or mesh.uv_layers.active + missing_source = source is None + if source is None: + source = _new_uv_layer(mesh, "UVMap") + _project_box_uv(mesh, source) + created += 1 + else: + repaired += _sanitize_uv_layer(source) + + if source.name != "UVMap" and mesh.uv_layers.get("UVMap") is None: + uv_map = _new_uv_layer(mesh, "UVMap") + for destination, original in zip( + uv_map.data, source.data, strict=False + ): + destination.uv = original.uv + source = uv_map + created += 1 + else: + source = mesh.uv_layers.get("UVMap") or source + + if mesh.uv_layers.get("Lightmap") is None: + if len(mesh.uv_layers) >= 8: + lightmap = mesh.uv_layers[-1] + if lightmap != source: + lightmap.name = "Lightmap" + else: + lightmap = _new_uv_layer(mesh, "Lightmap") + for destination, original in zip( + lightmap.data, source.data, strict=False + ): + destination.uv = original.uv + created += 1 + repaired += _sanitize_uv_layer(mesh.uv_layers.get("Lightmap")) + return created, missing_source, repaired + + +def _default_material() -> bpy.types.Material: + material = bpy.data.materials.get("SKATE_Auto_Concrete") + if material is None: + material = bpy.data.materials.new("SKATE_Auto_Concrete") + material.diffuse_color = (0.45, 0.45, 0.47, 1.0) + _auto_configure_material(material) + return material + + +def _first_collision_material( + obj: bpy.types.Object, + used_materials: set[str], + fallback: bpy.types.Material, +) -> bpy.types.Material: + for slot in obj.material_slots: + material = slot.material + if ( + material is not None + and material.name in used_materials + and bool(material.get("ow_collision_enabled", True)) + ): + return material + return fallback + + +def _auto_prepare_scene( + context, *, include_existing_roles: bool = False +) -> tuple[str, list[str]]: + global _SYNCING + _ALPHA_SCAN_CACHE.clear() + scene = context.scene + for name in _group_collections(): + _ensure_collection(scene, name) + migrated = _migrate_legacy_groups(scene) + + scene_meshes = [obj for obj in scene.objects if obj.type == "MESH"] + for obj in scene_meshes: + if obj.name == exporter.SPAWN_OBJECT or _is_auto_helper_object(obj): + continue + memberships = _object_groups(obj) + if memberships: + continue + if not _auto_visual_candidate(obj): + continue + # A conservative, visible baseline is predictable and safe: ordinary + # imported meshes collide until an explicit human/agent role says + # otherwise. Names never decide collision or export roles. + _move_to_group( + scene, obj, exporter.PRESENTATION_COLLISION_COLLECTION + ) + + active_visuals = exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_COLLISION_COLLECTION, + ) + active_visuals = [ + obj + for obj in active_visuals + if obj.type == "MESH" + and bool(obj.get("ow_export_visual", True)) + and not _is_auto_helper_object(obj) + ] + active_collision = exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_PRESENTATION_COLLECTION, + ) + active_collision = [ + obj + for obj in active_collision + if obj.type == "MESH" and not _is_auto_helper_object(obj) + ] + default_material = _default_material() + configured_materials = 0 + created_uv_layers = 0 + repaired_uv_coordinates = 0 + missing_source_uvs: list[str] = [] + visited_meshes: set[int] = set() + used_materials: set[str] = set() + for obj in active_visuals: + if not obj.data.materials: + obj.data.materials.append(default_material) + mesh_identity = obj.data.as_pointer() + if mesh_identity not in visited_meshes: + created, missing_source, repaired = _ensure_auto_uv_layers( + obj.data + ) + created_uv_layers += created + repaired_uv_coordinates += repaired + if missing_source: + missing_source_uvs.append(obj.name) + visited_meshes.add(mesh_identity) + for slot in obj.material_slots: + material = slot.material + if material is None: + continue + memberships = _object_groups(obj) + if len(memberships) == 1: + material["ow_map_group"] = memberships[0] + used_materials.add(material.name) + configured_materials += int( + _auto_configure_material(material, generate_maps=True) + ) + + baked_materials, bake_warnings = _bake_complex_base_colors( + context, active_visuals + ) + + fallback = next( + ( + bpy.data.materials.get(name) + for name in sorted(used_materials) + if bpy.data.materials.get(name) is not None + and bool( + bpy.data.materials[name].get( + "ow_collision_enabled", True + ) + ) + ), + default_material, + ) + for obj in active_collision: + material = _first_collision_material(obj, used_materials, fallback) + obj["ow_material"] = material.name + if "ow_upward_surface" not in obj: + obj["ow_upward_surface"] = False + was_syncing = _SYNCING + _SYNCING = True + try: + _hydrate_physics(obj) + finally: + _SYNCING = was_syncing + + local_lights = exporter._visible_local_light_objects() + + if scene.owned_world.map_name == "My Skate Map" and bpy.data.filepath: + scene.owned_world.map_name = Path(bpy.data.filepath).stem + if ( + scene.owned_world.output_path == "//my_skate_map.skate" + and bpy.data.filepath + ): + scene.owned_world.output_path = str( + Path(bpy.data.filepath).with_suffix(".skate") + ) + + warnings: list[str] = list(bake_warnings) + if missing_source_uvs: + warnings.append( + f"{len(missing_source_uvs)} mesh(es) had no source UVs; " + "generated normalized box UVs so textures remain usable. " + "Author UVs later for exact presentation." + ) + if repaired_uv_coordinates: + warnings.append( + f"Reset {repaired_uv_coordinates} invalid imported UV " + "coordinate(s) to zero." + ) + stats = ( + f"Auto-prepared {len(active_visuals)} presentation object(s), " + f"{len(active_collision)} collision object(s), " + f"{configured_materials} Blender material(s), " + f"{baked_materials} complex Base Color bake(s), " + f"{created_uv_layers} UV layer(s), and " + f"{len(local_lights)} real Blender local light(s)" + + (f"; migrated {migrated} legacy assignment(s)." if migrated else ".") + ) + return stats, warnings + + +def _validate_scene( + context, + *, + inspect_geometry: bool = True, +) -> tuple[list[str], list[str], str]: + issues: list[str] = [] + warnings: list[str] = [] + scene = context.scene + settings = scene.owned_world + + spawn = bpy.data.objects.get(exporter.SPAWN_OBJECT) + + visual_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_COLLISION_COLLECTION, + ) + if obj.type == "MESH" + and bool(obj.get("ow_export_visual", True)) + and not _is_auto_helper_object(obj) + ] + collision_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_PRESENTATION_COLLECTION, + ) + if obj.type == "MESH" and not _is_auto_helper_object(obj) + ] + grind_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.GRIND_COLLECTION + ) + if obj.type == "CURVE" + ] + npc_path_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.NPC_PATH_COLLECTION + ) + if obj.type == "CURVE" + ] + local_lights = exporter._visible_local_light_objects() + sun_lights = [ + obj + for obj in exporter._visible_light_objects() + if obj.data.type == "SUN" + ] + if len(sun_lights) > 1: + warnings.append( + f"{len(sun_lights)} Sun lights are enabled; " + f"{sun_lights[0].name!r} will control the world sun." + ) + + missing_groups = [ + name + for name in _group_collections() + if bpy.data.collections.get(name) is None + ] + if missing_groups: + issues.append( + "Missing map group collection(s): " + ", ".join(missing_groups) + ) + if not visual_objects: + issues.append("Groups 1 and 3 contain no presentation meshes.") + + for obj in scene.objects: + if ( + obj.name != exporter.SPAWN_OBJECT + and not _is_auto_helper_object(obj) + and not bool(obj.get("sk8_explicitly_ignored", False)) + and obj.type in {"CURVE", "FONT", "SURFACE", "META"} + and not _object_groups(obj) + and not obj.hide_render + ): + warnings.append( + f"{obj.name}: visible {obj.type} object is not exported; " + "convert it to a mesh in Blender when it belongs in the map." + ) + continue + if ( + obj.name == exporter.SPAWN_OBJECT + or _is_auto_helper_object(obj) + or bool(obj.get("sk8_explicitly_ignored", False)) + or obj.type not in {"MESH", "CURVE"} + ): + continue + memberships = _object_groups(obj) + if ( + not memberships + and obj.type == "MESH" + and not _auto_visual_candidate(obj) + ): + # Imported source scenes commonly retain disabled meshes and + # secondary LODs. Auto Prepare deliberately leaves them outside + # the export groups, so validation must ignore them too. + continue + if len(memberships) != 1: + issues.append( + f"{obj.name}: must belong to exactly one map group " + f"(currently {len(memberships)})." + ) + continue + expected_type = ( + "CURVE" + if memberships[0] + in {exporter.GRIND_COLLECTION, exporter.NPC_PATH_COLLECTION} + else "MESH" + ) + if obj.type != expected_type: + issues.append( + f"{obj.name}: {memberships[0]} requires " + f"{expected_type} objects." + ) + + used_materials: set[str] = set() + for obj in visual_objects: + mesh = obj.data + if mesh.uv_layers.get("UVMap") is None: + issues.append(f"{obj.name}: missing UVMap UV layer.") + if mesh.uv_layers.get("Lightmap") is None: + issues.append(f"{obj.name}: missing Lightmap UV layer.") + materials = [ + slot.material for slot in obj.material_slots + if slot.material is not None + ] + if not materials: + issues.append(f"{obj.name}: no material assigned.") + used_materials.update(material.name for material in materials) + for material in materials: + for property_name in ( + "ow_uv_map", + "ow_secondary_uv_map", + "ow_blend_mask_uv_map", + ): + uv_name = str(material.get(property_name, "")) + if uv_name and mesh.uv_layers.get(uv_name) is None: + warnings.append( + f"{obj.name}: material {material.name!r} requests " + f"missing UV layer {uv_name!r}; UVMap will be used." + ) + if ( + str(obj.get("ow_physics_type", "STATIC")) == "HINGED_DOOR" + and "ow_hinge_position" not in obj + ): + warnings.append( + f"{obj.name}: door hinge uses the object origin." + ) + + for material_name in sorted(used_materials, key=str.casefold): + material = bpy.data.materials.get(material_name) + if material is not None: + shader, shader_group_stack = _principled_context(material) + else: + shader, shader_group_stack = None, () + if shader is None: + warnings.append( + f"{material_name}: no supported Principled BSDF reaches the " + "Material Output; exporting the material's Blender viewport " + "colour. Connect a Principled shader for exact presentation." + ) + continue + base_socket = shader.inputs.get("Base Color") + base_source = _effective_input_source( + shader, "Base Color", shader_group_stack + ) + mix_layers = _ordinary_mix_layers(shader, shader_group_stack) + if ( + base_socket is not None + and base_socket.links + and _socket_image( + shader, "Base Color", shader_group_stack + ) is None + and mix_layers[0] is None + ): + warnings.append( + f"{material.name}: linked Base Color is procedural or " + "unsupported; exporting its Blender fallback colour. " + "Bake or assign an Albedo image for exact presentation." + ) + if ( + base_source is not None + and base_source[0].type in {"MIX", "MIX_RGB"} + and mix_layers[0] is None + ): + warnings.append( + f"{material.name}: Base Color uses a Mix setup that cannot " + "be represented directly. Only a standard two-image MIX " + "with a scalar or image factor is retained; bake this graph " + "for exact presentation." + ) + primary_image, secondary_image, mask_image, _factor, _channel = ( + mix_layers + ) + if secondary_image is not None and mask_image is not None: + secondary_uv = _image_uv_map_name(material, secondary_image) + mask_uv = _image_uv_map_name(material, mask_image) + secondary_transform = _image_uv_transform( + material, secondary_image + ) + mask_transform = _image_uv_transform(material, mask_image) + if ( + secondary_uv != mask_uv + or any( + abs(left - right) > 1.0e-6 + for left, right in zip( + secondary_transform, mask_transform + ) + ) + ): + warnings.append( + f"{material.name}: the secondary colour and its blend " + "mask use different UV mapping. SK8 stores one shared " + "secondary UV channel, so bake or align those mappings " + "for exact presentation." + ) + if primary_image is None: + primary_image = _socket_image( + shader, "Base Color", shader_group_stack + ) + if primary_image is not None: + primary_uv = _image_uv_map_name(material, primary_image) + primary_transform = _image_uv_transform( + material, primary_image + ) + for role, image_name in ( + ("Normal", str(material.get("ow_normal_image", ""))), + ("ORM", str(material.get("ow_orm_image", ""))), + ("Emissive", str(material.get("ow_emissive_image", ""))), + ): + role_image = bpy.data.images.get(image_name) + if role_image is None: + continue + role_uv = _image_uv_map_name(material, role_image) + role_transform = _image_uv_transform(material, role_image) + if ( + role_uv != primary_uv + or any( + abs(left - right) > 1.0e-6 + for left, right in zip( + role_transform, primary_transform + ) + ) + ): + warnings.append( + f"{material.name}: {role} uses UV mapping different " + "from Base Color. SK8 shares the base UV for these " + "maps, so bake or align that mapping for exact " + "presentation." + ) + normal_source = _effective_input_source( + shader, "Normal", shader_group_stack + ) + if normal_source is not None and normal_source[0].type == "BUMP": + warnings.append( + f"{material.name}: Blender Bump height shading cannot be " + "represented as a tangent normal map and was left off. " + "Bake it to a Normal Map for exact presentation." + ) + explicit_orm = bpy.data.images.get( + str(material.get("ow_orm_image", "")) + ) + linked_pbr = [ + socket_name + for socket_name in ("Roughness", "Metallic") + if _socket_has_effective_input( + shader, socket_name, shader_group_stack + ) + ] + if linked_pbr and explicit_orm is None: + warnings.append( + f"{material.name}: linked {'/'.join(linked_pbr)} requires " + "an explicit packed ORM image for exact channel detail; " + "exporting the material's scalar roughness/metallic values." + ) + + if not collision_objects: + issues.append("Groups 1 and 2 contain no collision meshes.") + for obj in collision_objects: + material_name = str(obj.get("ow_material", "")) + if not material_name: + issues.append( + f"{obj.name}: collision material is not assigned." + ) + elif material_name not in used_materials: + issues.append( + f"{obj.name}: collision material {material_name!r} is not " + "used by a presentation group." + ) + owner_name = str(obj.get("ow_map_object_owner", "")).strip() + if ( + settings.export_editable_objects + and owner_name + and owner_name not in { + visual.name for visual in visual_objects + } + ): + issues.append( + f"{obj.name}: editable collision owner {owner_name!r} " + "is not an exported presentation mesh." + ) + collision_audit = None + if collision_objects and inspect_geometry: + _triangles, collision_audit = exporter.audit_collision_geometry( + collision_objects, + include_editor_ownership=settings.export_editable_objects, + ) + issues.extend(collision_audit.issues) + warnings.extend(collision_audit.warnings) + + for obj in grind_objects: + if not any( + len(spline.points) >= 2 or len(spline.bezier_points) >= 2 + for spline in obj.data.splines + ): + warnings.append(f"{obj.name}: grind curve has fewer than 2 points.") + for obj in npc_path_objects: + if not any( + len(spline.points) >= 2 or len(spline.bezier_points) >= 2 + for spline in obj.data.splines + ): + issues.append(f"{obj.name}: NPC path has fewer than 2 points.") + + if spawn is None: + issues.append("Missing OW_SPAWN; select Create Spawn Locator.") + if not settings.map_name.strip(): + issues.append("Map Name cannot be empty.") + + raw_output = settings.output_path.strip() + if not raw_output: + issues.append("Choose an SKATE output file.") + elif Path(bpy.path.abspath(raw_output)).suffix.lower() != ".skate": + warnings.append("The .skate extension will be added automatically.") + + collision_stats = ( + f", {collision_audit.exported_triangles} usable collision triangle(s)" + if collision_audit is not None + else "" + ) + stats = ( + f"{len(visual_objects)} visual object(s), " + f"{len(collision_objects)} collision object(s), " + f"{len(grind_objects)} grind curve(s), " + f"{len(npc_path_objects)} NPC route(s), " + f"{len(used_materials)} material(s), " + f"{len(local_lights)} local light(s)" + f"{collision_stats}" + ) + return issues, warnings, stats + + +def _store_validation( + settings: OwnedWorldSceneSettings, + issues: list[str], + warnings: list[str], + stats: str, +) -> None: + lines = [ + *(f"ERROR: {message}" for message in issues), + *(f"WARNING: {message}" for message in warnings), + ] + settings.validation_details = "\n".join(lines) + if issues: + settings.last_status = ( + f"Map needs attention: {len(issues)} error(s), " + f"{len(warnings)} warning(s)." + ) + elif warnings: + settings.last_status = ( + f"Ready to export with {len(warnings)} warning(s): {stats}." + ) + else: + settings.last_status = f"Ready to export: {stats}." + + +def _resolved_output(settings: OwnedWorldSceneSettings) -> Path: + raw = settings.output_path.strip() + if not raw: + raw = "//my_skate_map.skate" + output = Path(bpy.path.abspath(raw)).resolve() + if output.suffix.lower() != ".skate": + output = output.with_suffix(".skate") + return output + + +def _run_export(context, output: Path) -> set[str]: + settings = context.scene.owned_world + _auto_stats, auto_warnings = _auto_prepare_scene(context) + _sync_scene(settings) + # The exporter performs the authoritative geometry audit once. Keeping + # this preflight structural-only avoids evaluating huge collision meshes + # twice during Quick Export. + issues, warnings, stats = _validate_scene( + context, inspect_geometry=False + ) + warnings = [*auto_warnings, *warnings] + _store_validation(settings, issues, warnings, stats) + if issues: + raise ValueError( + "Map validation failed. Open the Skate 3 Map panel for details." + ) + window_manager = context.window_manager + last_percent = -1 + window_manager.progress_begin(0, 100) + + def update_progress(fraction: float, stage: str) -> None: + nonlocal last_percent + percent = max(0, min(100, int(round(fraction * 100.0)))) + settings.last_status = f"Exporting {percent}% — {stage}" + window_manager.progress_update(percent) + workspace = getattr(context, "workspace", None) + if workspace is not None: + try: + workspace.status_text_set( + text=f"SKATE export: {percent}% — {stage}" + ) + except (AttributeError, RuntimeError): + pass + if percent == last_percent: + return + last_percent = percent + print( + f"SKATE progress: {percent}% — {stage}", + flush=True, + ) + if bpy.app.background: + return + screen = getattr(context, "screen", None) + if screen is not None: + for area in screen.areas: + area.tag_redraw() + try: + bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1) + except RuntimeError: + pass + + try: + result = exporter.export_scene( + output, + force_rebuild=settings.export_mode == "FORCE", + metadata_only=settings.export_mode == "METADATA", + export_editable_objects=settings.export_editable_objects, + progress=update_progress, + ) + finally: + window_manager.progress_end() + workspace = getattr(context, "workspace", None) + if workspace is not None: + try: + workspace.status_text_set(text=None) + except (AttributeError, RuntimeError): + pass + settings.output_path = str(result) + settings.last_status = f"Exported successfully: {result.name}" + collision_audit = exporter.LAST_COLLISION_AUDIT + if collision_audit is not None: + warnings.extend(collision_audit.warnings) + settings.validation_details = "\n".join( + f"WARNING: {message}" for message in warnings + ) + return {"FINISHED"} + + +def _store_export_failure( + settings: OwnedWorldSceneSettings, + error: Exception, +) -> None: + if isinstance(error, exporter.CollisionGeometryError): + _store_validation( + settings, + error.issues, + error.warnings, + "collision geometry audit", + ) + return + settings.last_status = f"Export failed: {error}" + if ( + isinstance(error, ValueError) + and settings.validation_details.startswith("ERROR:") + ): + return + settings.validation_details = f"ERROR: {error}" + + +def _ensure_spawn_locator(scene: bpy.types.Scene) -> bpy.types.Object: + existing = bpy.data.objects.get(exporter.SPAWN_OBJECT) + if existing is not None and existing.type == "MESH": + return existing + location = existing.location.copy() if existing is not None else None + rotation = ( + existing.rotation_euler.copy() if existing is not None else None + ) + if existing is not None: + bpy.data.objects.remove(existing, do_unlink=True) + mesh = bpy.data.meshes.new("OW_SPAWN_LOCATOR_MESH") + # Four-metre square pad plus a raised arrow pointing along local +Y. + vertices = [ + (-2.0, -2.0, 0.0), + (2.0, -2.0, 0.0), + (2.0, 2.0, 0.0), + (-2.0, 2.0, 0.0), + (-0.35, -1.15, 0.04), + (0.35, -1.15, 0.04), + (0.35, 0.65, 0.04), + (0.9, 0.65, 0.04), + (0.0, 1.65, 0.04), + (-0.9, 0.65, 0.04), + (-0.35, 0.65, 0.04), + ] + mesh.from_pydata( + vertices, + [(0, 1), (1, 2), (2, 3), (3, 0)], + [(4, 5, 6, 7, 8, 9, 10)], + ) + mesh.update() + spawn = bpy.data.objects.new(exporter.SPAWN_OBJECT, mesh) + scene.collection.objects.link(spawn) + spawn.location = location if location is not None else (0.0, 0.0, 0.0) + if rotation is not None: + spawn.rotation_euler = rotation + spawn.hide_render = True + spawn.show_in_front = True + spawn.color = (0.1, 0.9, 0.25, 0.65) + spawn["ow_spawn_locator"] = True + return spawn + + +class SKATE_OT_prepare_scene(Operator): + bl_idname = "skate_map.prepare_scene" + bl_label = "Prepare Scene" + bl_description = ( + "Create the five exclusive map groups and movable spawn locator" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + for name in _group_collections(): + _ensure_collection(context.scene, name) + _migrate_legacy_groups(context.scene) + _ensure_spawn_locator(context.scene) + _sync_scene(context.scene.owned_world) + context.scene.owned_world.last_status = ( + "Scene prepared. Move objects between the five map groups as needed." + ) + self.report({"INFO"}, "Owned World scene structure is ready") + return {"FINISHED"} + + +class SKATE_OT_auto_prepare_scene(Operator): + bl_idname = "skate_map.auto_prepare_scene" + bl_label = "Adopt Blender Scene" + bl_description = ( + "Adopt visible Blender meshes with a predictable collision-on " + "default, translate Principled materials, and preserve explicit " + "SKATE roles without guessing from names" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + stats, warnings = _auto_prepare_scene( + context, include_existing_roles=True + ) + settings = context.scene.owned_world + settings.last_status = stats + settings.validation_details = "\n".join( + f"WARNING: {warning}" for warning in warnings + ) + self.report({"INFO"}, stats) + return {"FINISHED"} + except Exception as error: + traceback.print_exc() + _store_export_failure(context.scene.owned_world, error) + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + + +class SKATE_OT_assign_selected(Operator): + bl_idname = "skate_map.assign_selected" + bl_label = "Assign Selected" + bl_description = "Assign selected objects to an SKATE role" + bl_options = {"REGISTER", "UNDO"} + + role: EnumProperty( + name="Role", + items=( + ("VISUAL", "Visual", "Rendered map geometry"), + ("COLLISION", "Collision", "Authoritative collision geometry"), + ("GRIND", "Grind", "Curve used as a grind path"), + ( + "NPC_PATH", + "NPC Path (Experimental)", + "Experimental route data for native AI skaters", + ), + ), + ) + + def execute(self, context): + if self.role in {"GRIND", "NPC_PATH"}: + objects = _selected_of_type(context, "CURVE") + collection_name = ( + exporter.GRIND_COLLECTION + if self.role == "GRIND" + else exporter.NPC_PATH_COLLECTION + ) + else: + objects = _selected_of_type(context, "MESH") + collection_name = ( + exporter.VISUAL_COLLECTION + if self.role == "VISUAL" + else exporter.COLLISION_COLLECTION + ) + if not objects: + expected = ( + "curves" if self.role in {"GRIND", "NPC_PATH"} else "meshes" + ) + self.report({"ERROR"}, f"Select one or more {expected}") + return {"CANCELLED"} + + collection = _ensure_collection(context.scene, collection_name) + missing_material: list[str] = [] + for obj in objects: + if self.role == "VISUAL": + destination = ( + exporter.PRESENTATION_COLLISION_COLLECTION + if exporter.NO_PRESENTATION_COLLECTION + in _object_groups(obj) + else exporter.NO_COLLISION_COLLECTION + ) + _move_to_group(context.scene, obj, destination) + obj["ow_export_visual"] = True + elif self.role == "COLLISION": + destination = ( + exporter.PRESENTATION_COLLISION_COLLECTION + if exporter.NO_COLLISION_COLLECTION in _object_groups(obj) + else exporter.NO_PRESENTATION_COLLECTION + ) + _move_to_group(context.scene, obj, destination) + material = obj.active_material + if material is None: + material = next( + ( + slot.material + for slot in obj.material_slots + if slot.material is not None + ), + None, + ) + if material is None: + missing_material.append(obj.name) + else: + obj.owned_world_physics.collision_material = material + elif self.role == "NPC_PATH": + _move_to_group( + context.scene, obj, exporter.NPC_PATH_COLLECTION + ) + _sync_npc_path(obj.owned_world_npc_path) + else: + _move_to_group(context.scene, obj, exporter.GRIND_COLLECTION) + + if missing_material: + self.report( + {"WARNING"}, + "Collision assigned, but these objects need a material: " + + ", ".join(missing_material[:4]), + ) + else: + self.report( + {"INFO"}, + f"Assigned {len(objects)} object(s) as {self.role.lower()}", + ) + return {"FINISHED"} + + +_GROUP_SHORT_NAMES = { + exporter.PRESENTATION_COLLISION_COLLECTION: "1 Presentation + Collision", + exporter.NO_PRESENTATION_COLLECTION: "2 No Presentation", + exporter.NO_COLLISION_COLLECTION: "3 No Collision", + exporter.GRIND_COLLECTION: "4 Grinds", + exporter.NPC_PATH_COLLECTION: "5 Pathing", +} + + +def _objects_using_material( + scene: bpy.types.Scene, material: bpy.types.Material +) -> list[bpy.types.Object]: + return [ + obj + for obj in scene.objects + if obj.type == "MESH" + and any(slot.material == material for slot in obj.material_slots) + and obj.name != exporter.SPAWN_OBJECT + and not _is_auto_helper_object(obj) + and (_object_groups(obj) or _auto_visual_candidate(obj)) + ] + + +def _material_group_status( + scene: bpy.types.Scene, material: bpy.types.Material +) -> str: + groups = { + group + for obj in _objects_using_material(scene, material) + for group in _object_groups(obj) + } + if len(groups) == 1: + return _GROUP_SHORT_NAMES[next(iter(groups))] + if len(groups) > 1: + return "Mixed groups" + authored = str(material.get("ow_map_group", "")) + return _GROUP_SHORT_NAMES.get(authored, "Unassigned") + + +class SKATE_UL_material_groups(UIList): + bl_idname = "SKATE_UL_material_groups" + + def draw_item( + self, + context, + layout, + _data, + item, + icon, + _active_data, + _active_property, + _index=0, + _filter_flag=0, + ): + if item is None: + return + row = layout.row(align=True) + row.label(text=item.name, icon_value=icon) + row.label(text=_material_group_status(context.scene, item)) + + +class SKATE_OT_set_material_group(Operator): + bl_idname = "skate_map.set_material_group" + bl_label = "Set Highlighted Material Group" + bl_description = ( + "Move every scene mesh using the highlighted material into this " + "exclusive map group" + ) + bl_options = {"REGISTER", "UNDO"} + + group: EnumProperty( + name="Group", + items=( + ( + exporter.PRESENTATION_COLLISION_COLLECTION, + "1: Presentation + Collision", + "Default rendered and collidable map geometry", + ), + ( + exporter.NO_PRESENTATION_COLLECTION, + "2: No Presentation", + "Collision-only meshes such as coping and effects", + ), + ( + exporter.NO_COLLISION_COLLECTION, + "3: No Collision", + "Rendered decals, vegetation, and phase-through geometry", + ), + ), + ) + + def execute(self, context): + materials = bpy.data.materials + if not materials: + self.report({"ERROR"}, "This file has no materials") + return {"CANCELLED"} + index = max( + 0, + min( + int(context.scene.owned_world.material_list_index), + len(materials) - 1, + ), + ) + material = materials[index] + objects = _objects_using_material(context.scene, material) + material["ow_map_group"] = self.group + for obj in objects: + _move_to_group(context.scene, obj, self.group) + obj["ow_export_visual"] = ( + self.group != exporter.NO_PRESENTATION_COLLECTION + ) + if not objects: + self.report( + {"WARNING"}, + f"Stored Group {_GROUP_SHORT_NAMES[self.group][0]} for " + f"{material.name}, but no scene meshes use it", + ) + else: + self.report( + {"INFO"}, + f"Moved {len(objects)} mesh(es) using {material.name} to " + f"{_GROUP_SHORT_NAMES[self.group]}", + ) + return {"FINISHED"} + + +class SKATE_OT_move_selected_to_group(Operator): + bl_idname = "skate_map.move_selected_to_group" + bl_label = "Move Selected to Map Group" + bl_description = ( + "Unlink selected objects from every map group and link them to " + "exactly one destination group" + ) + bl_options = {"REGISTER", "UNDO"} + + group: EnumProperty( + name="Group", + items=( + ( + exporter.PRESENTATION_COLLISION_COLLECTION, + "1: Presentation + Collision", + "Default rendered and collidable map geometry", + ), + ( + exporter.NO_PRESENTATION_COLLECTION, + "2: No Presentation", + "Collision-only meshes such as coping and effects", + ), + ( + exporter.NO_COLLISION_COLLECTION, + "3: No Collision", + "Rendered decals, vegetation, and phase-through geometry", + ), + ( + exporter.GRIND_COLLECTION, + "4: Grinds", + "Curve objects used as grind paths", + ), + ( + exporter.NPC_PATH_COLLECTION, + "5: Pathing", + "Curve objects used as pathing routes", + ), + ), + ) + + def execute(self, context): + objects = list(context.selected_objects) + expected = ( + "CURVE" + if self.group + in {exporter.GRIND_COLLECTION, exporter.NPC_PATH_COLLECTION} + else "MESH" + ) + objects = [obj for obj in objects if obj.type == expected] + if not objects: + self.report({"ERROR"}, f"Select one or more {expected} objects") + return {"CANCELLED"} + for obj in objects: + _move_to_group(context.scene, obj, self.group) + obj["ow_export_visual"] = self.group in { + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_COLLISION_COLLECTION, + } + for slot in obj.material_slots: + if slot.material is not None: + slot.material["ow_map_group"] = self.group + self.report( + {"INFO"}, f"Moved {len(objects)} object(s) to {self.group}" + ) + return {"FINISHED"} + + +class SKATE_OT_create_uv_layers(Operator): + bl_idname = "skate_map.create_uv_layers" + bl_label = "Create Missing UV Layers" + bl_description = ( + "Create UVMap and Lightmap layers, copying the active UVs when " + "available" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + objects = _selected_of_type(context, "MESH") + if not objects: + self.report({"ERROR"}, "Select one or more mesh objects") + return {"CANCELLED"} + created = 0 + empty_created = 0 + for obj in objects: + layers = obj.data.uv_layers + source = layers.active + uv_map = layers.get("UVMap") + if uv_map is None: + uv_map = _new_uv_layer(obj.data, "UVMap") + created += 1 + if source is not None and source != uv_map: + for destination, original in zip( + uv_map.data, source.data, strict=False + ): + destination.uv = original.uv + else: + empty_created += 1 + lightmap = layers.get("Lightmap") + if lightmap is None: + lightmap = _new_uv_layer(obj.data, "Lightmap") + created += 1 + for destination, original in zip( + lightmap.data, uv_map.data, strict=False + ): + destination.uv = original.uv + if empty_created: + self.report( + {"WARNING"}, + "Layers created; meshes without existing UVs still need " + "to be unwrapped", + ) + else: + self.report({"INFO"}, f"Created {created} missing UV layer(s)") + return {"FINISHED"} + + +class OWMATERIAL_OT_sync_materials(Operator): + bl_idname = "owmaterial.sync_materials" + bl_label = "Sync Materials" + bl_description = ( + "Refresh Owned World PBR settings from Principled shaders and " + "overwrite the add-on generated normal, ORM, and emissive maps" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + materials: list[bpy.types.Material] = [] + seen: set[int] = set() + objects = [ + obj for obj in context.selected_objects if obj.type == "MESH" + ] + if objects: + for obj in objects: + for slot in obj.material_slots: + material = slot.material + if ( + material is not None + and material.as_pointer() not in seen + ): + seen.add(material.as_pointer()) + materials.append(material) + elif getattr(context, "material", None) is not None: + materials.append(context.material) + if not materials: + self.report( + {"ERROR"}, "Select meshes with materials or an active material" + ) + return {"CANCELLED"} + for material in materials: + _auto_configure_material( + material, force=True, generate_maps=True + ) + self.report( + {"INFO"}, + f"Synced {len(materials)} material(s) and regenerated PBR maps", + ) + return {"FINISHED"} + + +def _sharp_edge_chains(mesh: bpy.types.Mesh) -> list[list[int]]: + sharp_attribute = mesh.attributes.get("sharp_edge") + sharp_edges: list[tuple[int, int, int]] = [] + for index, edge in enumerate(mesh.edges): + marked = ( + bool(sharp_attribute.data[index].value) + if sharp_attribute is not None + and sharp_attribute.domain == "EDGE" + else bool(getattr(edge, "use_edge_sharp", False)) + ) + if marked: + sharp_edges.append((index, edge.vertices[0], edge.vertices[1])) + adjacency: dict[int, list[tuple[int, int]]] = {} + for edge_index, first, second in sharp_edges: + adjacency.setdefault(first, []).append((edge_index, second)) + adjacency.setdefault(second, []).append((edge_index, first)) + used: set[int] = set() + chains: list[list[int]] = [] + + def walk(start: int, edge_index: int, next_vertex: int) -> list[int]: + chain = [start, next_vertex] + used.add(edge_index) + previous = start + current = next_vertex + while len(adjacency.get(current, ())) == 2: + candidates = [ + item + for item in adjacency[current] + if item[0] not in used and item[1] != previous + ] + if not candidates: + break + next_edge, destination = candidates[0] + used.add(next_edge) + chain.append(destination) + previous, current = current, destination + return chain + + for vertex, connected in adjacency.items(): + if len(connected) == 2: + continue + for edge_index, destination in connected: + if edge_index not in used: + chains.append(walk(vertex, edge_index, destination)) + for edge_index, first, second in sharp_edges: + if edge_index not in used: + chain = walk(first, edge_index, second) + if chain[-1] != chain[0]: + remaining = [ + item + for item in adjacency.get(chain[-1], ()) + if item[0] not in used + ] + while remaining: + next_edge, destination = remaining[0] + used.add(next_edge) + chain.append(destination) + remaining = [ + item + for item in adjacency.get(chain[-1], ()) + if item[0] not in used + ] + chains.append(chain) + return [chain for chain in chains if len(chain) >= 2] + + +class SKATE_OT_grinds_from_sharp_edges(Operator): + bl_idname = "skate_map.grinds_from_sharp_edges" + bl_label = "Create Grind Splines from Sharp Edges" + bl_description = ( + "Generate consolidated grind curves from edges marked Sharp on " + "selected meshes" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + sources = _selected_of_type(context, "MESH") + if not sources: + self.report({"ERROR"}, "Select one or more mesh objects") + return {"CANCELLED"} + created: list[bpy.types.Object] = [] + chain_count = 0 + for source in sources: + chains = _sharp_edge_chains(source.data) + if not chains: + continue + curve = bpy.data.curves.new( + f"{source.name}_Grinds", type="CURVE" + ) + curve.dimensions = "3D" + curve.resolution_u = 1 + for chain in chains: + spline = curve.splines.new("POLY") + spline.points.add(len(chain) - 1) + for point, vertex_index in zip( + spline.points, chain, strict=True + ): + point.co = (*source.data.vertices[vertex_index].co, 1.0) + obj = bpy.data.objects.new(f"{source.name}_Grinds", curve) + context.scene.collection.objects.link(obj) + obj.matrix_world = source.matrix_world.copy() + _move_to_group(context.scene, obj, exporter.GRIND_COLLECTION) + created.append(obj) + chain_count += len(chains) + if not created: + self.report( + {"ERROR"}, "Selected meshes contain no edges marked Sharp" + ) + return {"CANCELLED"} + bpy.ops.object.select_all(action="DESELECT") + for obj in created: + obj.select_set(True) + context.view_layer.objects.active = created[-1] + self.report( + {"INFO"}, + f"Created {chain_count} consolidated grind spline(s) " + f"in {len(created)} curve object(s)", + ) + return {"FINISHED"} + + +class SKATE_OT_set_spawn(Operator): + bl_idname = "skate_map.set_spawn" + bl_label = "Create / Select Spawn Locator" + bl_description = ( + "Create a movable four-metre spawn pad with a heading arrow, then " + "select it for positioning and rotation" + ) + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + spawn = _ensure_spawn_locator(context.scene) + bpy.ops.object.select_all(action="DESELECT") + spawn.select_set(True) + context.view_layer.objects.active = spawn + self.report( + {"INFO"}, + "Move and rotate OW_SPAWN; its arrow controls spawn heading", + ) + return {"FINISHED"} + + +class SKATE_OT_validate(Operator): + bl_idname = "skate_map.validate" + bl_label = "Validate Map" + bl_description = "Check the scene and explain anything blocking export" + + def execute(self, context): + settings = context.scene.owned_world + _auto_stats, auto_warnings = _auto_prepare_scene(context) + _sync_scene(settings) + issues, warnings, stats = _validate_scene(context) + warnings = [*auto_warnings, *warnings] + _store_validation(settings, issues, warnings, stats) + if issues: + self.report( + {"ERROR"}, + f"Found {len(issues)} error(s); see the Skate 3 Map panel", + ) + return {"CANCELLED"} + self.report( + {"INFO"}, + f"Map is ready ({len(warnings)} warning(s)): {stats}", + ) + return {"FINISHED"} + + +class SKATE_OT_quick_export(Operator): + bl_idname = "skate_map.quick_export" + bl_label = "Quick Export" + bl_description = "Export directly to the configured SKATE file" + + def execute(self, context): + try: + return _run_export( + context, _resolved_output(context.scene.owned_world) + ) + except Exception as error: + traceback.print_exc() + _store_export_failure(context.scene.owned_world, error) + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + + +class SKATE_OT_export_dialog(Operator, ExportHelper): + bl_idname = "skate_map.export_dialog" + bl_label = "Export Skate 3 Custom Engine Map" + bl_description = "Validate and export this scene as an SKATE package" + filename_ext = ".skate" + + filter_glob: StringProperty(default="*.skate", options={"HIDDEN"}) + + def invoke(self, context, event): + self.filepath = str(_resolved_output(context.scene.owned_world)) + return ExportHelper.invoke(self, context, event) + + def execute(self, context): + try: + output = Path(self.filepath).resolve() + if output.suffix.lower() != ".skate": + output = output.with_suffix(".skate") + return _run_export(context, output) + except Exception as error: + traceback.print_exc() + _store_export_failure(context.scene.owned_world, error) + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + + +class SKATE_OT_open_output_folder(Operator): + bl_idname = "skate_map.open_output_folder" + bl_label = "Open Output Folder" + bl_description = "Open the folder containing the exported SKATE" + + def execute(self, context): + folder = _resolved_output(context.scene.owned_world).parent + folder.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.path_open(filepath=str(folder)) + return {"FINISHED"} + + +class OWMATERIAL_OT_apply_preset(Operator): + bl_idname = "owmaterial.apply_preset" + bl_label = "Apply Owned Material Preset" + bl_options = {"REGISTER", "UNDO"} + + preset: EnumProperty(name="Preset", items=PRESET_ITEMS) + + def execute(self, context): + material = getattr(context, "material", None) + if material is None and context.object is not None: + material = context.object.active_material + if material is None: + self.report({"ERROR"}, "No active material") + return {"CANCELLED"} + audio, physics, pattern, roughness, metallic, alpha = PRESETS[ + self.preset + ] + settings = material.owned_world + settings.audio_surface = str(audio) + settings.physics_surface = str(physics) + settings.surface_pattern = str(pattern) + settings.roughness = roughness + settings.metallic = metallic + settings.alpha_mode = str(alpha) + if self.preset == "DECAL": + settings.presentation_type = "DECAL" + settings.collision_enabled = False + elif self.preset == "TREE": + settings.presentation_type = "VEGETATION" + settings.collision_enabled = False + settings.alpha_cutoff = 0.42 + else: + settings.presentation_type = "STANDARD" + _sync(settings) + self.report({"INFO"}, f"Applied {self.preset} to {material.name}") + return {"FINISHED"} + + +class OWMATERIAL_PT_material(Panel): + bl_label = "Owned World Material" + bl_idname = "OWMATERIAL_PT_material" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "material" + + @classmethod + def poll(cls, context): + return context.material is not None + + def draw(self, context): + layout = self.layout + settings = context.material.owned_world + + presets = layout.box() + presets.label(text="Quick Presets") + grid = presets.grid_flow(columns=2, even_columns=True) + for identifier, label, _description in PRESET_ITEMS: + operator = grid.operator( + "owmaterial.apply_preset", text=label + ) + operator.preset = identifier + + game = layout.box() + game.label(text="Skate 3 Contact") + game.prop(settings, "audio_surface") + game.prop(settings, "physics_surface") + game.prop(settings, "surface_pattern") + game.prop(settings, "collision_enabled") + game.prop(settings, "friction") + game.prop(settings, "restitution") + + pbr = layout.box() + pbr.label(text="PBR Presentation") + pbr.operator( + "owmaterial.sync_materials", + text="Sync Materials from Shaders", + icon="FILE_REFRESH", + ) + pbr.prop(settings, "presentation_type") + pbr.prop(settings, "albedo_image") + pbr.prop(settings, "lightmap_image") + pbr.prop(settings, "normal_image") + pbr.prop(settings, "orm_image") + pbr.prop(settings, "emissive_image") + pbr.prop(settings, "roughness") + pbr.prop(settings, "metallic") + pbr.prop(settings, "baked_strength") + pbr.prop(settings, "emissive_strength") + pbr.prop(settings, "alpha_mode") + if settings.alpha_mode == "1": + pbr.prop(settings, "alpha_cutoff") + pbr.label(text="ORM channels: R=AO, G=Roughness, B=Metallic") + + +class OWPHYSICS_PT_object(Panel): + bl_label = "Owned World Physics" + bl_idname = "OWPHYSICS_PT_object" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "physics" + + @classmethod + def poll(cls, context): + return context.object is not None and context.object.type == "MESH" + + def draw(self, context): + layout = self.layout + obj = context.object + settings = obj.owned_world_physics + collision = layout.box() + collision.label(text="Collision Authoring") + collision.prop(settings, "collision_material") + collision.prop(settings, "upward_surface") + layout.prop(settings, "physics_type") + if settings.physics_type in {"BOX3D_STATIC", "BOX3D_DYNAMIC"}: + body = layout.box() + body.label(text="Box3D Body") + body.prop(settings, "box3d_collision_shape") + if settings.physics_type == "BOX3D_DYNAMIC": + body.prop(settings, "box3d_density") + body.prop(settings, "box3d_linear_damping") + body.prop(settings, "box3d_angular_damping") + body.prop(settings, "box3d_gravity_scale") + body.prop(settings, "box3d_enable_sleep") + if settings.box3d_enable_sleep: + body.prop(settings, "box3d_initially_awake") + breaking = layout.box() + breaking.label(text="Breakable Group") + breaking.prop(settings, "box3d_break_group") + if settings.box3d_break_group > 0: + breaking.prop(settings, "box3d_break_speed_threshold") + breaking.prop(settings, "box3d_break_impulse_scale") + breaking.prop(settings, "box3d_break_angular_impulse") + breaking.prop(settings, "box3d_break_gravity_scale") + contact = layout.box() + contact.label(text="Box3D Contact") + contact.prop(settings, "box3d_friction") + contact.prop(settings, "box3d_restitution") + return + if settings.physics_type != "HINGED_DOOR": + return + hinge = layout.box() + hinge.label(text="Hinge") + hinge.operator("owphysics.hinge_from_cursor") + hinge.prop(settings, "hinge_axis") + if "ow_hinge_position" in obj: + hinge.label( + text="Hinge: {:.2f}, {:.2f}, {:.2f}".format( + *obj["ow_hinge_position"] + ) + ) + else: + hinge.label(text="Hinge uses object origin", icon="INFO") + limits = layout.box() + limits.label(text="Opening Limits") + limits.prop(settings, "minimum_angle") + limits.prop(settings, "maximum_angle") + limits.prop(settings, "initial_angle") + body = layout.box() + body.label(text="Rigid Body") + body.prop(settings, "mass") + body.prop(settings, "angular_damping") + body.prop(settings, "return_spring_strength") + body.prop(settings, "maximum_angular_speed") + body.prop(settings, "contact_impulse_scale") + body.prop(settings, "friction") + body.prop(settings, "restitution") + body.label(text="Push motion is contact-driven at runtime") + + +class OWNPCPATH_PT_object(Panel): + bl_label = "Owned World NPC Path (Experimental)" + bl_idname = "OWNPCPATH_PT_object" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + + @classmethod + def poll(cls, context): + obj = context.object + collection = bpy.data.collections.get(exporter.NPC_PATH_COLLECTION) + return ( + obj is not None + and obj.type == "CURVE" + and collection is not None + and obj.name in collection.all_objects + ) + + def draw(self, context): + layout = self.layout + settings = context.object.owned_world_npc_path + layout.label(text="Experimental Native AI Route", icon="ERROR") + layout.label(text="Reliable route following is not yet supported.") + layout.prop(settings, "skater_count") + layout.prop(settings, "speed") + layout.prop(settings, "spawn_spacing") + layout.label( + text="Enable Cyclic on the spline for a continuous loop.", + icon="INFO", + ) + + +def _draw_map_panel(layout, context) -> None: + settings = context.scene.owned_world + + intro = layout.box() + intro.label(text="Skate 3 Custom Engine Map", icon="WORLD") + intro.label(text="Open a Blender map, set spawn, then export.") + intro.operator( + "skate_map.auto_prepare_scene", + text="Auto Prepare Blender Map", + icon="IMPORT", + ) + intro.label( + text="Export also auto-prepares an untouched scene.", + icon="INFO", + ) + + authoring = layout.box() + authoring.label(text="Material Map Groups", icon="MATERIAL") + if bpy.data.materials: + authoring.template_list( + "SKATE_UL_material_groups", + "", + bpy.data, + "materials", + settings, + "material_list_index", + rows=6, + ) + buttons = authoring.column(align=True) + for group, label, icon in ( + ( + exporter.PRESENTATION_COLLISION_COLLECTION, + "1 Presentation + Collision (Default)", + "MESH_CUBE", + ), + ( + exporter.NO_PRESENTATION_COLLECTION, + "2 No Presentation", + "MOD_PHYSICS", + ), + ( + exporter.NO_COLLISION_COLLECTION, + "3 No Collision", + "CANCEL", + ), + ): + operator = buttons.operator( + "skate_map.set_material_group", text=label, icon=icon + ) + operator.group = group + authoring.label( + text="Buttons affect every mesh using the highlighted material.", + icon="INFO", + ) + else: + authoring.label(text="No materials in this file.", icon="ERROR") + + curves = authoring + curves.separator() + curves.label(text="Curve Groups use selected curves.", icon="CURVE_DATA") + for group, label, icon in ( + ( + exporter.GRIND_COLLECTION, + "4 Grinds", + "CURVE_DATA", + ), + ( + exporter.NPC_PATH_COLLECTION, + "5 Pathing", + "OUTLINER_OB_CURVE", + ), + ): + operator = curves.operator( + "skate_map.move_selected_to_group", text=label, icon=icon + ) + operator.group = group + + tools = layout.box() + tools.label(text="Authoring Tools", icon="TOOL_SETTINGS") + tools.operator("skate_map.create_uv_layers", icon="GROUP_UVS") + tools.operator( + "skate_map.grinds_from_sharp_edges", icon="CURVE_DATA" + ) + tools.operator( + "skate_map.prepare_scene", + text="Create Empty Authoring Structure", + icon="TOOL_SETTINGS", + ) + tools.label( + text="Material and door controls are in Properties.", + icon="INFO", + ) + + spawn = layout.box() + spawn.label(text="Player Spawn", icon="EMPTY_ARROWS") + spawn.operator("skate_map.set_spawn", icon="ORIENTATION_GLOBAL") + spawn.label(text="Move the pad; rotate its arrow for heading.", icon="INFO") + + world = layout.box() + world.label(text="Map and Lighting", icon="LIGHT_SUN") + world.label( + text="Normal Blender lights export automatically.", + icon="LIGHT", + ) + world.prop(settings, "map_name") + world.prop(settings, "cycle_seconds") + world.prop(settings, "dynamic_lighting_enabled_by_default") + world.prop(settings, "cycle_ping_pong") + row = world.row(align=True) + row.prop(settings, "start_hour") + if settings.cycle_ping_pong: + row.prop(settings, "end_hour") + world.prop(settings, "orbit_azimuth") + world.prop( + settings, + "show_sky_colours", + icon="TRIA_DOWN" if settings.show_sky_colours else "TRIA_RIGHT", + emboss=False, + ) + if settings.show_sky_colours: + sky = world.column(align=True) + sky.label(text="Day Palette") + sky.prop(settings, "sky_zenith") + sky.prop(settings, "sky_horizon") + sky.prop(settings, "sky_nadir") + world.prop( + settings, + "show_advanced_lighting", + icon="TRIA_DOWN" if settings.show_advanced_lighting else "TRIA_RIGHT", + ) + if settings.show_advanced_lighting: + if settings.show_sky_colours: + grade = world.column(align=True) + grade.label(text="Sky Colour") + grade.prop(settings, "sky_tint") + twilight = world.column(align=True) + twilight.label(text="Twilight Palette") + twilight.prop(settings, "twilight_zenith") + twilight.prop(settings, "twilight_horizon") + twilight.prop(settings, "twilight_nadir") + night = world.column(align=True) + night.label(text="Night Palette") + night.prop(settings, "night_zenith") + night.prop(settings, "night_horizon") + night.prop(settings, "night_nadir") + lights = world.column(align=True) + lights.label(text="Directional and Ambient Light") + lights.prop(settings, "sun_color") + lights.prop(settings, "sun_intensity") + lights.prop(settings, "moon_color") + lights.prop(settings, "moon_intensity") + lights.prop(settings, "day_ambient") + lights.prop(settings, "night_ambient") + + output = layout.box() + output.label(text="Validate and Export", icon="EXPORT") + output.prop(settings, "output_path") + output.prop(settings, "export_mode") + editable_row = output.row() + editable_row.enabled = settings.export_mode != "METADATA" + editable_row.prop(settings, "export_editable_objects") + if ( + settings.export_mode != "METADATA" + and not settings.export_editable_objects + ): + output.label( + text="Existing map geometry will be static in the editor.", + icon="LOCKED", + ) + row = output.row(align=True) + row.operator("skate_map.validate", icon="CHECKMARK") + row.operator("skate_map.quick_export", icon="FILE_TICK") + row = output.row(align=True) + row.operator("skate_map.export_dialog", text="Export As...", icon="EXPORT") + row.operator( + "skate_map.open_output_folder", text="Open Folder", icon="FILE_FOLDER" + ) + + status_icon = ( + "ERROR" + if settings.last_status.startswith(("Map needs", "Export failed")) + else "INFO" + ) + output.label(text=settings.last_status, icon=status_icon) + for line in settings.validation_details.splitlines()[:20]: + icon = "ERROR" if line.startswith("ERROR:") else "INFO" + output.label(text=line, icon=icon) + remaining = len(settings.validation_details.splitlines()) - 20 + if remaining > 0: + output.label( + text=f"...and {remaining} more. Fix these first, then validate." + ) + + +class SKATE_PT_sidebar(Panel): + bl_label = "Map Builder" + bl_idname = "SKATE_PT_sidebar" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Skate 3 Map" + + def draw(self, context): + _draw_map_panel(self.layout, context) + + +class SKATE_PT_scene(Panel): + bl_label = "Skate 3 Custom Engine Map" + bl_idname = "SKATE_PT_scene" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + + def draw(self, context): + _draw_map_panel(self.layout, context) + + +def _hydrate_material(material: Material) -> None: + settings = material.owned_world + settings.audio_surface = str( + max(0, min(93, int(material.get("ow_audio_surface", 3)))) + ) + settings.physics_surface = str( + max(0, min(13, int(material.get("ow_physics_surface", 1)))) + ) + settings.surface_pattern = str( + max(0, min(15, int(material.get("ow_surface_pattern", 0)))) + ) + settings.alpha_mode = str( + max(0, min(2, int(material.get("ow_alpha_mode", 0)))) + ) + settings.alpha_cutoff = float(material.get("ow_alpha_cutoff", 0.5)) + settings.roughness = float(material.get("ow_roughness", 0.78)) + settings.metallic = float(material.get("ow_metallic", 0.0)) + settings.friction = float(material.get("ow_friction", 0.82)) + settings.restitution = float(material.get("ow_restitution", 0.0)) + settings.emissive_strength = float(material.get("ow_emissive", 0.0)) + settings.baked_strength = float( + material.get("ow_baked_strength", 1.0) + ) + settings.collision_enabled = bool( + material.get("ow_collision_enabled", True) + ) + presentation_type = str( + material.get("ow_presentation_type", "STANDARD") + ) + settings.presentation_type = ( + presentation_type + if presentation_type in {"STANDARD", "DECAL", "VEGETATION"} + else "STANDARD" + ) + for attribute, property_name in ( + ("albedo_image", "ow_albedo_image"), + ("lightmap_image", "ow_lightmap_image"), + ("normal_image", "ow_normal_image"), + ("orm_image", "ow_orm_image"), + ("emissive_image", "ow_emissive_image"), + ): + setattr( + settings, + attribute, + bpy.data.images.get(str(material.get(property_name, ""))), + ) + + +def _hydrate_physics(obj: Object) -> None: + settings = obj.owned_world_physics + settings.collision_material = bpy.data.materials.get( + str(obj.get("ow_material", "")) + ) + settings.upward_surface = bool(obj.get("ow_upward_surface", False)) + physics_type = str(obj.get("ow_physics_type", "STATIC")) + settings.physics_type = ( + physics_type + if physics_type + in {"STATIC", "BOX3D_STATIC", "BOX3D_DYNAMIC", "HINGED_DOOR"} + else "STATIC" + ) + shape = str(obj.get("ow_box3d_collision_shape", "BOX")) + settings.box3d_collision_shape = ( + shape if shape in {"BOX", "SPHERE", "CONVEX_HULL"} else "BOX" + ) + settings.box3d_density = max( + 1.0, min(5000.0, float(obj.get("ow_box3d_density", 100.0))) + ) + settings.box3d_friction = max( + 0.0, min(2.0, float(obj.get("ow_box3d_friction", 0.55))) + ) + settings.box3d_restitution = max( + 0.0, min(1.0, float(obj.get("ow_box3d_restitution", 0.05))) + ) + settings.box3d_linear_damping = max( + 0.0, min( + 20.0, float(obj.get("ow_box3d_linear_damping", 0.05)) + ) + ) + settings.box3d_angular_damping = max( + 0.0, min( + 20.0, float(obj.get("ow_box3d_angular_damping", 0.15)) + ) + ) + settings.box3d_gravity_scale = max( + 0.0, min(4.0, float(obj.get("ow_box3d_gravity_scale", 1.0))) + ) + settings.box3d_enable_sleep = bool( + obj.get("ow_box3d_enable_sleep", True) + ) + settings.box3d_initially_awake = bool( + obj.get("ow_box3d_initially_awake", True) + ) + settings.box3d_break_group = max( + 0, min(65535, int(obj.get("ow_box3d_break_group", 0))) + ) + settings.box3d_break_speed_threshold = max( + 0.1, + min( + 30.0, + float(obj.get("ow_box3d_break_speed_threshold", 2.5)), + ), + ) + settings.box3d_break_impulse_scale = max( + 0.0, + min(10.0, float(obj.get("ow_box3d_break_impulse_scale", 0.45))), + ) + settings.box3d_break_angular_impulse = max( + 0.0, + min(10.0, float(obj.get("ow_box3d_break_angular_impulse", 0.08))), + ) + settings.box3d_break_gravity_scale = max( + 0.0, + min(4.0, float(obj.get("ow_box3d_break_gravity_scale", 1.0))), + ) + settings.hinge_axis = tuple(obj.get("ow_hinge_axis", (0.0, 0.0, 1.0))) + settings.minimum_angle = math.radians( + float(obj.get("ow_door_min_angle_degrees", -105.0)) + ) + settings.maximum_angle = math.radians( + float(obj.get("ow_door_max_angle_degrees", 105.0)) + ) + settings.initial_angle = math.radians( + float(obj.get("ow_door_initial_angle_degrees", 0.0)) + ) + settings.mass = float(obj.get("ow_door_mass", 32.0)) + settings.angular_damping = float( + obj.get("ow_door_angular_damping", 2.2) + ) + settings.return_spring_strength = float( + obj.get("ow_door_return_spring_strength", 0.0) + ) + settings.maximum_angular_speed = float( + obj.get("ow_door_maximum_angular_speed", 8.0) + ) + settings.contact_impulse_scale = float( + obj.get("ow_door_contact_impulse_scale", 1.0) + ) + settings.friction = float(obj.get("ow_door_friction", 0.55)) + settings.restitution = float(obj.get("ow_door_restitution", 0.02)) + + +def _hydrate_npc_path(obj: Object) -> None: + settings = obj.owned_world_npc_path + settings.skater_count = max( + 1, min(32, int(obj.get("ow_npc_skater_count", 1))) + ) + settings.speed = max( + 0.1, min(30.0, float(obj.get("ow_npc_speed", 5.5))) + ) + settings.spawn_spacing = max( + 0.0, min(100.0, float(obj.get("ow_npc_spawn_spacing", 3.0))) + ) + + +def _hydrate_scene(scene: Scene) -> None: + settings = scene.owned_world + settings.map_name = str(scene.get("ow_map_name", "My Skate Map")) + settings.cycle_seconds = float(scene.get("ow_cycle_seconds", 96.0)) + settings.start_hour = float(scene.get("ow_start_hour", 9.0)) + settings.end_hour = float(scene.get("ow_end_hour", 17.0)) + settings.cycle_ping_pong = bool( + scene.get("ow_cycle_ping_pong", True) + ) + settings.dynamic_lighting_enabled_by_default = bool( + scene.get("ow_dynamic_lighting_enabled_by_default", True) + ) + settings.orbit_azimuth = float(scene.get("ow_orbit_azimuth", 0.62)) + settings.sky_zenith = tuple( + scene.get("ow_sky_zenith", (0.09, 0.34, 0.72)) + ) + settings.sky_horizon = tuple( + scene.get("ow_sky_horizon", (0.58, 0.78, 0.98)) + ) + settings.sky_nadir = tuple( + scene.get("ow_sky_nadir", (0.18, 0.25, 0.34)) + ) + settings.twilight_zenith = tuple( + scene.get("ow_twilight_zenith", (0.045, 0.10, 0.26)) + ) + settings.twilight_horizon = tuple( + scene.get("ow_twilight_horizon", (1.0, 0.32, 0.10)) + ) + settings.twilight_nadir = tuple( + scene.get("ow_twilight_nadir", (0.05, 0.035, 0.06)) + ) + settings.night_zenith = tuple( + scene.get("ow_night_zenith", (0.007, 0.015, 0.045)) + ) + settings.night_horizon = tuple( + scene.get("ow_night_horizon", (0.045, 0.085, 0.17)) + ) + settings.night_nadir = tuple( + scene.get("ow_night_nadir", (0.008, 0.014, 0.032)) + ) + settings.sun_color = tuple( + scene.get("ow_sun_color", (1.0, 0.92, 0.78)) + ) + settings.moon_color = tuple( + scene.get("ow_moon_color", (0.42, 0.56, 0.92)) + ) + settings.sun_intensity = float(scene.get("ow_sun_intensity", 1.25)) + settings.moon_intensity = float(scene.get("ow_moon_intensity", 0.18)) + settings.day_ambient = float(scene.get("ow_day_ambient", 0.32)) + settings.night_ambient = float(scene.get("ow_night_ambient", 0.11)) + settings.sky_tint = tuple( + scene.get("ow_sky_tint", (1.0, 1.0, 1.0)) + ) + spawn = bpy.data.objects.get(exporter.SPAWN_OBJECT) + settings.spawn_heading = ( + exporter._spawn_heading(spawn) + if spawn is not None + else 0.0 + ) + + +def _hydrate_all() -> None: + global _SYNCING + _SYNCING = True + try: + for material in bpy.data.materials: + _hydrate_material(material) + for obj in bpy.data.objects: + if obj.type == "MESH": + _hydrate_physics(obj) + elif obj.type == "CURVE": + _hydrate_npc_path(obj) + for scene in bpy.data.scenes: + _hydrate_scene(scene) + finally: + _SYNCING = False + + +@persistent +def _load_post(_unused) -> None: + _hydrate_all() + + +def _file_export_menu(self, _context) -> None: + self.layout.operator( + SKATE_OT_export_dialog.bl_idname, + text="Skate 3 Custom Engine Map (.skate)", + ) + + +CLASSES = ( + OwnedWorldMaterialSettings, + OwnedWorldPhysicsSettings, + OwnedWorldNpcPathSettings, + OwnedWorldSceneSettings, + SKATE_OT_prepare_scene, + SKATE_OT_auto_prepare_scene, + SKATE_OT_assign_selected, + SKATE_UL_material_groups, + SKATE_OT_set_material_group, + SKATE_OT_move_selected_to_group, + SKATE_OT_create_uv_layers, + SKATE_OT_grinds_from_sharp_edges, + SKATE_OT_set_spawn, + SKATE_OT_validate, + SKATE_OT_quick_export, + SKATE_OT_export_dialog, + SKATE_OT_open_output_folder, + OWMATERIAL_OT_apply_preset, + OWMATERIAL_OT_sync_materials, + OWPHYSICS_OT_hinge_from_cursor, + OWMATERIAL_PT_material, + OWPHYSICS_PT_object, + OWNPCPATH_PT_object, + SKATE_PT_sidebar, + SKATE_PT_scene, +) + + +def register() -> None: + for cls in CLASSES: + try: + bpy.utils.register_class(cls) + except ValueError: + pass + if not hasattr(Material, "owned_world"): + Material.owned_world = PointerProperty( + type=OwnedWorldMaterialSettings + ) + if not hasattr(Object, "owned_world_physics"): + Object.owned_world_physics = PointerProperty( + type=OwnedWorldPhysicsSettings + ) + if not hasattr(Object, "owned_world_npc_path"): + Object.owned_world_npc_path = PointerProperty( + type=OwnedWorldNpcPathSettings + ) + if not hasattr(Scene, "owned_world"): + Scene.owned_world = PointerProperty(type=OwnedWorldSceneSettings) + if _load_post not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(_load_post) + bpy.types.TOPBAR_MT_file_export.append(_file_export_menu) + _hydrate_all() + + +def unregister() -> None: + try: + bpy.types.TOPBAR_MT_file_export.remove(_file_export_menu) + except RuntimeError: + pass + if _load_post in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(_load_post) + if hasattr(Scene, "owned_world"): + del Scene.owned_world + if hasattr(Object, "owned_world_physics"): + del Object.owned_world_physics + if hasattr(Object, "owned_world_npc_path"): + del Object.owned_world_npc_path + if hasattr(Material, "owned_world"): + del Material.owned_world + for cls in reversed(CLASSES): + try: + bpy.utils.unregister_class(cls) + except RuntimeError: + pass + + +if __name__ == "__main__": + register() diff --git a/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/blender_manifest.toml b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/blender_manifest.toml new file mode 100644 index 00000000..0f250cdd --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/blender_manifest.toml @@ -0,0 +1,13 @@ +schema_version = "1.0.0" +id = "owned_world_materials" +version = "1.15.0" +name = "Owned World Authoring" +tagline = "Build, validate, and export Skate 3 Custom Engine maps" +maintainer = "Skate 3 Custom Engine Layer contributors" +type = "add-on" + +blender_version_min = "5.0.0" + +license = [ + "SPDX:MIT", +] diff --git a/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/exporter.py b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/exporter.py new file mode 100644 index 00000000..5854736d --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/owned_world_material_addon/exporter.py @@ -0,0 +1,4841 @@ +"""Original Blender -> SKATE v15 exporter. + +This module intentionally targets the narrow project-owned scene contract +documented beside it. It has no ArenaBuilder imports or runtime dependency. +""" + +from __future__ import annotations + +from array import array +from dataclasses import dataclass +from pathlib import Path +import hashlib +import io +import json +import math +import os +import struct +import sys +import time +import zlib +from typing import BinaryIO, Callable + +import bpy +from mathutils import Vector +try: + import numpy +except ImportError: + numpy = None +try: + from compression import zstd +except ImportError: + zstd = None + + +# The Rust runtime requests the existing v14 storage contract. This changes +# compression only; material, vertex, collision and native spline data remain. +COMPAT14 = os.environ.get("SKATE_EXPORT_COMPAT14") == "1" +MAGIC = b"SKATE14\0" if COMPAT14 else b"SKATE15\0" +ENDIAN_MARKER = 0x12345678 +STORAGE_RAW = 0 +STORAGE_DEFLATE = 1 +STORAGE_ZSTD = 2 +STORAGE_ZSTD_RGBA_FILTER = 3 +STORAGE_ZSTD_VERTEX_SOA = 4 +STORAGE_ZSTD_INDEX_DELTA = 5 +STORAGE_ZSTD_COLLISION_INDEXED = 6 +STORAGE_DEFLATE_RGBA_FILTER = 7 +STORAGE_DEFLATE_VERTEX_SOA = 8 +STORAGE_DEFLATE_INDEX_DELTA = 9 +STORAGE_DEFLATE_COLLISION_INDEXED = 10 +STORAGE_TEXTURE_REFERENCE = 11 +PRESENTATION_COLLISION_COLLECTION = "OW_GROUP_1_PRESENTATION_COLLISION" +NO_PRESENTATION_COLLECTION = "OW_GROUP_2_NO_PRESENTATION" +NO_COLLISION_COLLECTION = "OW_GROUP_3_NO_COLLISION" +GRIND_COLLECTION = "OW_GROUP_4_GRINDS" +NPC_PATH_COLLECTION = "OW_GROUP_5_PATHING" +# Public aliases retained for scripts written against add-on 1.x. New scenes +# use the five exclusive collections above. +VISUAL_COLLECTION = PRESENTATION_COLLISION_COLLECTION +COLLISION_COLLECTION = NO_PRESENTATION_COLLECTION +LEGACY_VISUAL_COLLECTION = "OW_VISUAL" +LEGACY_COLLISION_COLLECTION = "OW_COLLISION" +LEGACY_GRIND_COLLECTION = "OW_GRIND" +LEGACY_NPC_PATH_COLLECTION = "OW_NPC_PATHS" +GROUP_COLLECTIONS = ( + PRESENTATION_COLLISION_COLLECTION, + NO_PRESENTATION_COLLECTION, + NO_COLLISION_COLLECTION, + GRIND_COLLECTION, + NPC_PATH_COLLECTION, +) +SPAWN_OBJECT = "OW_SPAWN" +_HELPER_OBJECT_MARKERS = ( + "character_size", + "character size", + "player_size", + "player size", + "scale_reference", + "scale reference", +) +CACHE_SCHEMA = 22 +METADATA_FLOAT_COUNT = 49 +METADATA_BYTE_COUNT = METADATA_FLOAT_COUNT * 4 +RETAIL_NORMAL_ATTRIBUTE = "skate3_retail_normal" +RETAIL_TANGENT_ATTRIBUTE = "skate3_retail_tangent" +# University files created before the usage-6 semantic was verified contain +# the retail tangent under this incorrect name. Read it as a tangent so those +# large working files remain exportable without a destructive migration. +LEGACY_RETAIL_TANGENT_ATTRIBUTE = "skate3_retail_binormal" +RETAIL_HANDEDNESS_ATTRIBUTE = "skate3_retail_tangent_handedness" +RETAIL_EDGE_CODE_ATTRIBUTES = tuple( + f"skate3_retail_edge_code_{corner}" for corner in range(3) +) +EDITOR_COLLISION_OWNER_ATTRIBUTE = "ow_editor_collision_owner" + + +@dataclass +class ExportMaterial: + blender_material: bpy.types.Material + material_id: int + export_name: str + display_color: tuple[float, float, float] + albedo_texture: int + lightmap_texture: int + normal_texture: int + orm_texture: int + emissive_texture: int + secondary_albedo_texture: int + blend_mask_texture: int + retail_shader_name: str + retail_shader_family: int + retail_render_flags: int + retail_material_guid: int + retail_material_handle: int + retail_material_group_index: int + retail_texture_bindings: list[tuple[str, int, int, int, int]] + retail_parameters: list[tuple[str, list[str]]] + retail_source_metadata: str + + +@dataclass +class ExportHingedDoor: + name: str + hinge_position: tuple[float, float, float] + hinge_axis: tuple[float, float, float] + closed_width_axis: tuple[float, float, float] + closed_depth_axis: tuple[float, float, float] + local_min: tuple[float, float, float] + local_max: tuple[float, float, float] + minimum_angle_radians: float + maximum_angle_radians: float + initial_angle_radians: float + mass: float + angular_damping: float + return_spring_strength: float + maximum_angular_speed: float + contact_impulse_scale: float + friction: float + restitution: float + surface_id: int + vertices: list[tuple] + indices: list[int] + collision: list[tuple] + + +@dataclass +class PackedVisualGeometry: + vertex_chunks: list[bytes] + index_chunks: list[bytes] + vertex_count: int + index_count: int + objects: list["ExportMapObject"] + + +@dataclass +class ExportMapObject: + source_identity: int + object_id: int + name: str + origin: tuple[float, float, float] + first_index: int + index_count: int + editor_editable: bool + physics_type: int + collision_shape: int + density: float + friction: float + restitution: float + linear_damping: float + angular_damping: float + gravity_scale: float + enable_sleep: bool + initially_awake: bool + break_group: int + break_speed_threshold: float + break_impulse_scale: float + break_angular_impulse: float + break_gravity_scale: float + + +@dataclass +class ExportLocalLight: + name: str + light_type: int + position: tuple[float, float, float] + direction: tuple[float, float, float] + color: tuple[float, float, float] + intensity: float + influence_radius: float + source_radius: float + spot_inner_cosine: float + spot_outer_cosine: float + + +@dataclass +class ExportGrindRail: + name: str + closed: bool + points: list[tuple[float, float, float]] + retail_spline_id: int = 0 + retail_type_signature: int = 0 + retail_flags: int = 0 + retail_trailing_word: int = 0 + native_segment_payload: bytes = b"" + parent_source_identity: int = 0 + + +@dataclass +class SceneContentFingerprint: + digest: str + visual_vertices: int + visual_indices: int + collision_triangles: int + grind_rails: int + npc_routes: int + hinged_doors: int + local_lights: int + + +@dataclass +class CollisionGeometryAudit: + issues: list[str] + warnings: list[str] + source_triangles: int + exported_triangles: int + skipped_degenerate: int + skipped_duplicates: int + + +class CollisionGeometryError(ValueError): + def __init__(self, issues: list[str], warnings: list[str]) -> None: + self.issues = list(issues) + self.warnings = list(warnings) + summary = "Collision validation failed:\n" + "\n".join( + f"- {issue}" for issue in self.issues + ) + super().__init__(summary) + + +LAST_COLLISION_AUDIT: CollisionGeometryAudit | None = None +ProgressCallback = Callable[[float, str], None] + + +def _report_progress( + callback: ProgressCallback | None, + fraction: float, + stage: str, +) -> None: + if callback is not None: + callback(max(0.0, min(1.0, float(fraction))), stage) + + +def _write_u32(stream: BinaryIO, value: int) -> None: + stream.write(struct.pack(" None: + stream.write(struct.pack(" None: + if not math.isfinite(value): + raise ValueError("SKATE does not permit non-finite floats") + stream.write(struct.pack(" None: + for component in value: + _write_f32(stream, float(component)) + + +def _write_string(stream: BinaryIO, value: str) -> None: + encoded = value.encode("utf-8") + _write_u32(stream, len(encoded)) + stream.write(encoded) + + +def _write_stored_bytes( + stream: BinaryIO, + decoded: bytes, +) -> tuple[int, int]: + candidates = [ + (STORAGE_DEFLATE, zlib.compress(decoded, level=9)), + ] + if zstd is not None: + candidates.append((STORAGE_ZSTD, zstd.compress(decoded, level=10))) + method, payload = min(candidates, key=lambda item: len(item[1])) + if len(payload) >= len(decoded): + method, payload = STORAGE_RAW, decoded + _write_u32(stream, method) + _write_u32(stream, len(payload)) + stream.write(payload) + return method, len(payload) + + +def _write_transformed_bytes( + stream: BinaryIO, + decoded: bytes, + transformed: bytes, + *, + zstd_method: int, + deflate_method: int, +) -> tuple[int, int]: + if COMPAT14: + return _write_stored_bytes(stream, decoded) + prefix = struct.pack("= len(decoded): + method, payload = STORAGE_RAW, decoded + _write_u32(stream, method) + _write_u32(stream, len(payload)) + stream.write(payload) + return method, len(payload) + + +def _filter_rgba8(decoded: bytes, width: int, height: int) -> bytes: + row_size = width * 4 + if len(decoded) != row_size * height: + raise ValueError("RGBA8 texture dimensions do not match its bytes") + output = bytearray(struct.pack("= 4 else 0) + ) + & 0xFF + for index, value in enumerate(row) + ) + up = bytes( + (value - previous[index]) & 0xFF + for index, value in enumerate(row) + ) + options = (row, sub, up) + scores = [ + sum(min(value, 256 - value) for value in option) + for option in options + ] + filter_type = min(range(len(scores)), key=scores.__getitem__) + filtered = options[filter_type] + output.append(filter_type) + output.extend(filtered) + previous = row + return bytes(output) + + +def _vertex_soa(decoded: bytes) -> bytes: + vertex_size = 56 + if len(decoded) % vertex_size: + raise ValueError("visual vertex block is not SKATE15-compatible") + fields = ((0, 12), (12, 12), (24, 8), (32, 8), (40, 4), + (44, 8), (52, 4)) + output = bytearray(len(decoded)) + offset = 0 + for field_offset, field_size in fields: + for vertex in range(0, len(decoded), vertex_size): + output[offset : offset + field_size] = decoded[ + vertex + field_offset : vertex + field_offset + field_size + ] + offset += field_size + return bytes(output) + + +def _index_delta_varints(decoded: bytes) -> bytes: + if len(decoded) % 4: + raise ValueError("visual index block is not u32-aligned") + output = bytearray() + previous = 0 + for (current,) in struct.iter_unpack("= 0 else ((-delta << 1) - 1) + while zigzag >= 0x80: + output.append((zigzag & 0x7F) | 0x80) + zigzag >>= 7 + output.append(zigzag) + previous = current + return bytes(output) + + +def _indexed_collision(decoded: bytes) -> bytes: + triangle = struct.Struct("<9fII4B") + if len(decoded) % triangle.size: + raise ValueError("collision block is not SKATE15-compatible") + vertices: list[bytes] = [] + vertex_indices: dict[bytes, int] = {} + records = bytearray() + for values in triangle.iter_unpack(decoded): + packed_vertices = struct.pack("<9f", *values[:9]) + for corner in range(3): + packed = packed_vertices[corner * 12 : corner * 12 + 12] + index = vertex_indices.get(packed) + if index is None: + index = len(vertices) + vertex_indices[packed] = index + vertices.append(packed) + records.extend(struct.pack(" tuple[int, int]: + return _write_stored_bytes(stream, b"".join(chunks)) + + +def _map_object_extension( + geometry: PackedVisualGeometry, + collision_ranges: dict[int, tuple[int, int]], + rails: list[ExportGrindRail], + export_editable_objects: bool, +) -> bytes: + payload = io.BytesIO() + editable_objects = ( + [ + record + for record in geometry.objects + if record.editor_editable + ] + if export_editable_objects + else [] + ) + _write_u32(payload, len(editable_objects)) + for record in editable_objects: + first_collision, collision_count = collision_ranges.get( + record.source_identity, (0, 0) + ) + _write_u32(payload, record.object_id) + _write_string(payload, record.name) + _write_vec(payload, record.origin) + _write_u32(payload, record.first_index) + _write_u32(payload, record.index_count) + _write_u32(payload, first_collision) + _write_u32(payload, collision_count) + grind_indices = [ + index + for index, rail in enumerate(rails) + if rail.parent_source_identity == record.source_identity + ] + _write_u32(payload, len(grind_indices)) + for grind_index in grind_indices: + _write_u32(payload, grind_index) + _write_u32(payload, record.physics_type) + _write_u32(payload, record.collision_shape) + _write_f32(payload, record.density) + _write_f32(payload, record.friction) + _write_f32(payload, record.restitution) + _write_f32(payload, record.linear_damping) + _write_f32(payload, record.angular_damping) + _write_f32(payload, record.gravity_scale) + _write_u32(payload, 1 if record.enable_sleep else 0) + _write_u32(payload, 1 if record.initially_awake else 0) + return payload.getvalue() + +def _break_group_extension(geometry: PackedVisualGeometry) -> bytes: + payload = io.BytesIO() + records = [ + record for record in geometry.objects + if record.editor_editable and record.break_group != 0 + ] + _write_u32(payload, len(records)) + for record in records: + _write_u32(payload, record.object_id) + _write_u32(payload, record.break_group) + _write_f32(payload, record.break_speed_threshold) + _write_f32(payload, record.break_impulse_scale) + _write_f32(payload, record.break_angular_impulse) + _write_f32(payload, record.break_gravity_scale) + return payload.getvalue() + + +def _cache_manifest_path(output: Path) -> Path: + return output.with_name(output.name + ".export-cache.json") + + +def _hash_text(digest, value: object) -> None: + encoded = str(value).encode("utf-8") + digest.update(struct.pack(" None: + for value in values: + digest.update(struct.pack(" None: + count = len(collection) * component_count + if typecode == "f": + values = array("f", [0.0]) * count + else: + values = array(typecode, [0]) * count + if count: + collection.foreach_get(property_name, values) + if sys.byteorder != "little": + values.byteswap() + digest.update(values.tobytes()) + + +def _hash_matrix(digest, matrix) -> None: + for row in matrix: + _hash_floats(digest, row) + + +def _hash_image_source(digest, image: bpy.types.Image) -> None: + _hash_text(digest, image.name) + _hash_text(digest, image.source) + _hash_text(digest, image.colorspace_settings.name) + digest.update(struct.pack(" tuple[bpy.types.Mesh, bpy.types.Object | None]: + # Blender 5.1 can keep newly-created UV layers off an already-built + # evaluated mesh even after the source datablock is tagged and the view + # layer is refreshed. An unmodified mesh does not need evaluation, so use + # its authoritative source data directly. Modified or shape-keyed meshes + # still use Blender's evaluated result. + source_mesh = source_object.data + if not source_object.modifiers and source_mesh.shape_keys is None: + return source_mesh, None + + evaluated = source_object.evaluated_get(depsgraph) + mesh = ( + evaluated.to_mesh( + preserve_all_data_layers=True, depsgraph=depsgraph + ) + if preserve_all_data_layers + else evaluated.to_mesh() + ) + if mesh is None: + raise ValueError( + f"Blender could not evaluate mesh {source_object.name!r}" + ) + return mesh, evaluated + + +def _visual_uv_layers( + mesh: bpy.types.Mesh, source_name: str +) -> tuple[bpy.types.MeshUVLoopLayer, bpy.types.MeshUVLoopLayer]: + uv0 = mesh.uv_layers.get("UVMap") + if uv0 is None: + raise ValueError( + f"visual mesh {source_name!r} requires a UVMap UV layer" + ) + # The package always stores two UV streams, but unlit maps do not need + # authors to manufacture a duplicate Blender lightmap layer. + return uv0, mesh.uv_layers.get("Lightmap") or uv0 + + +def _retail_world_frame_attributes(mesh): + normal = mesh.attributes.get(RETAIL_NORMAL_ATTRIBUTE) + tangent = mesh.attributes.get(RETAIL_TANGENT_ATTRIBUTE) + legacy_tangent = mesh.attributes.get(LEGACY_RETAIL_TANGENT_ATTRIBUTE) + handedness = mesh.attributes.get(RETAIL_HANDEDNESS_ATTRIBUTE) + if all( + attribute is None + for attribute in (normal, tangent, legacy_tangent, handedness) + ): + return None + if tangent is not None and legacy_tangent is not None: + raise ValueError( + f"mesh {mesh.name!r} has both " + f"{RETAIL_TANGENT_ATTRIBUTE!r} and the legacy " + f"{LEGACY_RETAIL_TANGENT_ATTRIBUTE!r}; remove the legacy " + "attribute" + ) + tangent = tangent if tangent is not None else legacy_tangent + names = ( + RETAIL_NORMAL_ATTRIBUTE, + RETAIL_TANGENT_ATTRIBUTE, + RETAIL_HANDEDNESS_ATTRIBUTE, + ) + attributes = (normal, tangent, handedness) + if any(attribute is None for attribute in attributes): + missing = [ + name + for name, attribute in zip(names, attributes, strict=True) + if attribute is None + ] + raise ValueError( + f"mesh {mesh.name!r} has an incomplete retail world frame; " + f"missing {', '.join(missing)}" + ) + expected = ( + (normal, "POINT", "FLOAT_VECTOR"), + (tangent, "POINT", "FLOAT_VECTOR"), + (handedness, "POINT", "FLOAT"), + ) + for attribute, domain, data_type in expected: + if ( + attribute.domain != domain + or attribute.data_type != data_type + or len(attribute.data) != len(mesh.vertices) + ): + raise ValueError( + f"mesh {mesh.name!r} retail attribute {attribute.name!r} " + f"must be {domain}/{data_type} with one value per vertex" + ) + return normal, tangent, handedness + + +def _hash_mesh( + digest, + source_object: bpy.types.Object, + *, + visual: bool, + depsgraph, +) -> tuple[int, int]: + mesh, evaluated = _mesh_for_export( + source_object, + depsgraph, + preserve_all_data_layers=visual, + ) + try: + mesh.calc_loop_triangles() + _hash_text(digest, source_object.name_full) + _hash_matrix(digest, source_object.matrix_world) + digest.update( + struct.pack( + "<4f", + *(float(value) for value in source_object.color), + ) + ) + digest.update( + struct.pack( + " SceneContentFingerprint: + started = time.perf_counter() + print( + "SKATE cache: fingerprinting scene content", + f"visual_objects={len(visual_objects)}", + f"collision_objects={len(collision_objects)}", + flush=True, + ) + digest = hashlib.sha256() + digest.update(f"SKATE_EXPORT_CACHE_{CACHE_SCHEMA}".encode("ascii")) + _hash_text( + digest, + f"EXPORT_EDITABLE_OBJECTS={int(export_editable_objects)}", + ) + _hash_text( + digest, + "DYNAMIC_LIGHTING_DEFAULT=" + + str( + int( + bool( + bpy.context.scene.get( + "ow_dynamic_lighting_enabled_by_default", True + ) + ) + ) + ), + ) + + material_properties = ( + "ow_flags", + "ow_friction", + "ow_restitution", + "ow_display_color", + "ow_roughness", + "ow_emissive", + "ow_albedo_image", + "ow_secondary_albedo_image", + "ow_blend_mask_image", + "ow_lightmap_image", + "ow_lightmap_encoding", + "ow_baked_strength", + "ow_normal_image", + "ow_orm_image", + "ow_emissive_image", + "ow_alpha_mode", + "ow_alpha_cutoff", + "ow_blend_factor", + "ow_blend_mask_channel", + "ow_albedo_address_mode", + "ow_secondary_address_mode", + "ow_blend_mask_address_mode", + "ow_cull_mode", + "ow_uv_map", + "ow_secondary_uv_map", + "ow_blend_mask_uv_map", + "ow_uv_transform", + "ow_secondary_uv_transform", + "ow_blend_mask_uv_transform", + "ow_audio_surface", + "ow_physics_surface", + "ow_surface_pattern", + "ow_depth_layer", + "ow_collision_enabled", + "skate3_shader_name", + "skate3_retail_material_guid", + "skate3_retail_material_handle", + "skate3_retail_material_group_index", + "skate3_retail_texture_ids", + "skate3_retail_parameters", + "skate3_retail_source", + ) + for material in materials: + _hash_text(digest, material.name) + _hash_floats(digest, material.diffuse_color) + for property_name in material_properties: + _hash_text(digest, property_name) + _hash_text(digest, repr(material.get(property_name, None))) + retail_manifest = bpy.data.texts.get("SKATE3_RETAIL_MANIFEST") + if retail_manifest is not None: + _hash_text(digest, retail_manifest.as_string()) + for image_index, image in enumerate(images, start=1): + _hash_image_source(digest, image) + _report_progress( + progress, + 0.15 * image_index / max(1, len(images)), + f"Hashing textures ({image_index}/{len(images)}): " + f"{image.name}", + ) + + depsgraph = bpy.context.evaluated_depsgraph_get() + total_objects = max( + 1, + len(visual_objects) + + len(collision_objects) + + len(grind_objects) + + len(npc_path_objects) + + len(_visible_local_light_objects()), + ) + processed_objects = 0 + + def object_complete(label: str) -> None: + nonlocal processed_objects + processed_objects += 1 + _report_progress( + progress, + 0.15 + 0.85 * processed_objects / total_objects, + label, + ) + + visual_vertices = 0 + visual_indices = 0 + hinged_doors = 0 + for obj in visual_objects: + if obj.type != "MESH": + continue + object_vertices, _ = _hash_mesh( + digest, obj, visual=True, depsgraph=depsgraph + ) + if str(obj.get("ow_physics_type", "STATIC")) == "HINGED_DOOR": + hinged_doors += 1 + else: + visual_vertices += object_vertices + visual_indices += object_vertices + object_complete(f"Hashing visuals: {obj.name}") + + for obj in collision_objects: + if obj.type != "MESH": + continue + material = bpy.data.materials.get(str(obj.get("ow_material", ""))) + if material is not None and not bool( + material.get("ow_collision_enabled", True) + ): + continue + _hash_text( + digest, + int( + bool( + obj.get( + "ow_preserve_opposite_wound_collision", + False, + ) + ) + ), + ) + preserve_retail_codes = bool( + obj.get("ow_preserve_retail_edge_codes", False) + ) + _hash_text(digest, int(preserve_retail_codes)) + editor_owner = obj.data.attributes.get( + EDITOR_COLLISION_OWNER_ATTRIBUTE + ) + _hash_text(digest, EDITOR_COLLISION_OWNER_ATTRIBUTE) + if editor_owner is None: + _hash_text(digest, "MISSING") + else: + _hash_text(digest, editor_owner.domain) + _hash_text(digest, editor_owner.data_type) + _hash_foreach( + digest, + editor_owner.data, + "value", + 1, + "i", + ) + if preserve_retail_codes: + for attribute_name in RETAIL_EDGE_CODE_ATTRIBUTES: + attribute = obj.data.attributes.get(attribute_name) + _hash_text(digest, attribute_name) + if attribute is None: + _hash_text(digest, "MISSING") + else: + _hash_text(digest, attribute.domain) + _hash_text(digest, attribute.data_type) + _hash_foreach( + digest, + attribute.data, + "value", + 1, + "i", + ) + _hash_mesh( + digest, obj, visual=False, depsgraph=depsgraph + ) + object_complete(f"Hashing collision: {obj.name}") + + grind_rails = 0 + for obj in grind_objects: + if obj.type != "CURVE": + continue + _hash_text(digest, obj.name_full) + _hash_matrix(digest, obj.matrix_world) + for spline in obj.data.splines: + _hash_text(digest, spline.type) + _hash_text(digest, int(bool(spline.use_cyclic_u))) + if spline.type == "POLY": + _hash_foreach(digest, spline.points, "co", 4, "f") + if len(spline.points) >= 2: + grind_rails += 1 + elif spline.type == "BEZIER": + _hash_foreach(digest, spline.bezier_points, "co", 3, "f") + _hash_foreach( + digest, spline.bezier_points, "handle_left", 3, "f" + ) + _hash_foreach( + digest, spline.bezier_points, "handle_right", 3, "f" + ) + if len(spline.bezier_points) >= 2: + grind_rails += 1 + for property_name in ( + "skate3_retail_grind", + "skate3_retail_grind_spline_id", + "skate3_retail_grind_type_signature", + "skate3_retail_grind_flags", + "skate3_retail_grind_trailing_word", + "skate3_retail_grind_segment_count", + "skate3_retail_grind_segment_payload", + ): + _hash_text(digest, repr(obj.get(property_name, None))) + _hash_text( + digest, + obj.parent.name_full + if obj.parent is not None and obj.parent.type == "MESH" + else "", + ) + object_complete(f"Hashing grind paths: {obj.name}") + + npc_routes = 0 + for obj in npc_path_objects: + if obj.type != "CURVE": + continue + _hash_text(digest, obj.name_full) + _hash_matrix(digest, obj.matrix_world) + for property_name, default in ( + ("ow_npc_skater_count", 1), + ("ow_npc_speed", 5.5), + ("ow_npc_spawn_spacing", 3.0), + ): + _hash_text(digest, repr(obj.get(property_name, default))) + for spline in obj.data.splines: + _hash_text(digest, spline.type) + _hash_text(digest, int(bool(spline.use_cyclic_u))) + if spline.type == "POLY": + _hash_foreach(digest, spline.points, "co", 4, "f") + if len(spline.points) >= 2: + npc_routes += 1 + elif spline.type == "BEZIER": + _hash_foreach(digest, spline.bezier_points, "co", 3, "f") + if len(spline.bezier_points) >= 2: + npc_routes += 1 + object_complete(f"Hashing NPC paths: {obj.name}") + + local_lights = 0 + scene_eevee = getattr(bpy.context.scene, "eevee", None) + _hash_text( + digest, + repr(float(getattr(scene_eevee, "light_threshold", 0.01))), + ) + for obj in _visible_local_light_objects(): + light = obj.data + local_lights += 1 + _hash_text(digest, obj.name_full) + _hash_matrix(digest, obj.matrix_world) + for value in ( + light.type, + tuple(light.color), + light.energy, + bool(getattr(light, "use_custom_distance", False)), + light.cutoff_distance, + light.shadow_soft_size, + getattr(light, "diffuse_factor", 1.0), + getattr(light, "specular_factor", 1.0), + getattr(light, "volume_factor", 1.0), + getattr(light, "size", 0.0), + getattr(light, "size_y", 0.0), + getattr(light, "shape", ""), + getattr(light, "spot_size", 0.0), + getattr(light, "spot_blend", 0.0), + ): + _hash_text(digest, repr(value)) + object_complete(f"Hashing lights: {obj.name}") + + result = SceneContentFingerprint( + digest=digest.hexdigest(), + visual_vertices=visual_vertices, + visual_indices=visual_indices, + collision_triangles=collision_triangle_count, + grind_rails=grind_rails, + npc_routes=npc_routes, + hinged_doors=hinged_doors, + local_lights=local_lights, + ) + print( + "SKATE cache: fingerprint complete", + f"seconds={time.perf_counter() - started:.3f}", + f"digest={result.digest[:16]}", + flush=True, + ) + return result + + +def _visible_light_objects() -> list[bpy.types.Object]: + return sorted( + ( + obj + for obj in bpy.data.objects + if obj.type == "LIGHT" + and not obj.hide_render + and math.isfinite(float(obj.data.energy)) + and float(obj.data.energy) > 0.0 + ), + key=lambda item: item.name_full, + ) + + +def _visible_local_light_objects() -> list[bpy.types.Object]: + return [ + obj + for obj in _visible_light_objects() + if obj.data.type in {"POINT", "SPOT", "AREA"} + ] + + +def _sun_metadata() -> tuple[tuple[float, float, float], float, float]: + scene = bpy.context.scene + sun_color = tuple(scene.get("ow_sun_color", (1.0, 0.92, 0.78))) + sun_intensity = float(scene.get("ow_sun_intensity", 1.25)) + orbit_azimuth = float(scene.get("ow_orbit_azimuth", 0.62)) + suns = [ + obj for obj in _visible_light_objects() if obj.data.type == "SUN" + ] + if not suns: + return sun_color, sun_intensity, orbit_azimuth + + sun = suns[0] + emitted = sun.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0)) + runtime_emitted = _to_runtime(emitted) + direction_to_light = ( + -runtime_emitted[0], + -runtime_emitted[1], + -runtime_emitted[2], + ) + horizontal_length = math.hypot( + direction_to_light[0], direction_to_light[2] + ) + if horizontal_length > 1.0e-5: + orbit_azimuth = math.atan2( + direction_to_light[2], direction_to_light[0] + ) + return ( + tuple(float(value) for value in sun.data.color), + max(0.0, float(sun.data.energy) * 0.42), + orbit_azimuth, + ) + + +def _read_package_header(output: Path) -> tuple[str, int, tuple[int, ...]]: + with output.open("rb") as stream: + if stream.read(len(MAGIC)) != MAGIC: + raise ValueError(f"{output} is not an SKATE v15 package") + marker = struct.unpack(" str: + digest = hashlib.sha256() + excluded_ranges = [ + (metadata_offset, metadata_offset + METADATA_BYTE_COUNT), + ] + if world_config_value_offset is not None: + excluded_ranges.append( + (world_config_value_offset, world_config_value_offset + 4) + ) + excluded_ranges.sort() + with output.open("rb") as stream: + cursor = 0 + for excluded_start, excluded_end in excluded_ranges: + remaining = excluded_start - cursor + if remaining < 0: + raise ValueError("SKATE cache exclusions overlap") + while remaining: + data = stream.read(min(8 * 1024 * 1024, remaining)) + if not data: + raise ValueError( + f"{output} ended before its cached metadata" + ) + digest.update(data) + remaining -= len(data) + stream.seek(excluded_end - excluded_start, os.SEEK_CUR) + cursor = excluded_end + while True: + data = stream.read(8 * 1024 * 1024) + if not data: + break + digest.update(data) + return digest.hexdigest() + + +def _scene_metadata(output: Path) -> tuple[str, bytes]: + spawn = bpy.data.objects.get(SPAWN_OBJECT) + if spawn is None: + raise ValueError(f"required spawn object is missing: {SPAWN_OBJECT}") + scene = bpy.context.scene + map_name = str(scene.get("ow_map_name", output.stem)) + sun_color, sun_intensity, orbit_azimuth = _sun_metadata() + values = ( + *_to_runtime(spawn.matrix_world.translation), + _spawn_heading(spawn), + *tuple(scene.get("ow_sky_zenith", (0.09, 0.34, 0.72))), + *tuple(scene.get("ow_sky_horizon", (0.58, 0.78, 0.98))), + *tuple(scene.get("ow_sky_nadir", (0.18, 0.25, 0.34))), + float(scene.get("ow_cycle_seconds", 96.0)), + float(scene.get("ow_start_hour", 9.0)), + orbit_azimuth, + float(scene.get("ow_end_hour", 17.0)), + 1.0 if bool(scene.get("ow_cycle_ping_pong", False)) else 0.0, + *tuple(scene.get("ow_twilight_zenith", (0.045, 0.10, 0.26))), + *tuple(scene.get("ow_twilight_horizon", (1.0, 0.32, 0.10))), + *tuple(scene.get("ow_twilight_nadir", (0.05, 0.035, 0.06))), + *tuple(scene.get("ow_night_zenith", (0.007, 0.015, 0.045))), + *tuple(scene.get("ow_night_horizon", (0.045, 0.085, 0.17))), + *tuple(scene.get("ow_night_nadir", (0.008, 0.014, 0.032))), + *sun_color, + *tuple(scene.get("ow_moon_color", (0.42, 0.56, 0.92))), + sun_intensity, + float(scene.get("ow_moon_intensity", 0.18)), + float(scene.get("ow_day_ambient", 0.32)), + float(scene.get("ow_night_ambient", 0.11)), + *tuple(scene.get("ow_sky_tint", (1.0, 1.0, 1.0))), + ) + if len(values) != METADATA_FLOAT_COUNT or not all( + math.isfinite(float(value)) for value in values + ): + raise ValueError( + f"SKATE metadata must contain {METADATA_FLOAT_COUNT} finite floats" + ) + return map_name, struct.pack("<49f", *(float(value) for value in values)) + + +def _patch_package_metadata(output: Path, manifest: dict | None = None) -> None: + map_name, metadata = _scene_metadata(output) + package_name, metadata_offset, _ = _read_package_header(output) + if package_name != map_name: + raise ValueError( + "metadata-only export cannot change the map name; use a full " + f"export ({package_name!r} != {map_name!r})" + ) + with output.open("r+b") as stream: + stream.seek(metadata_offset) + stream.write(metadata) + if manifest is not None: + world_config_value_offset = manifest.get( + "world_config_value_offset" + ) + if not isinstance(world_config_value_offset, int): + raise ValueError( + "metadata-only export requires a fresh SKATE v15 cache; " + "run one normal export" + ) + stream.seek(world_config_value_offset - 20) + if stream.read(20) != struct.pack( + "<4sIIII", b"WCFG", 1, 4, STORAGE_RAW, 4 + ): + raise ValueError( + "cached dynamic-lighting metadata is invalid; " + "run one normal export" + ) + _write_u32( + stream, + 1 + if bool( + bpy.context.scene.get( + "ow_dynamic_lighting_enabled_by_default", True + ) + ) + else 0, + ) + stream.flush() + os.fsync(stream.fileno()) + + +def _load_cache_manifest(output: Path) -> dict | None: + path = _cache_manifest_path(output) + if not path.is_file(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return value if isinstance(value, dict) else None + + +def _write_cache_manifest( + output: Path, + fingerprint: SceneContentFingerprint, + material_count: int, + texture_count: int, + world_config_value_offset: int | None = None, +) -> None: + package_name, metadata_offset, counts = _read_package_header(output) + output_stat = output.stat() + manifest = { + "schema": CACHE_SCHEMA, + "package_name": package_name, + "output_length": output_stat.st_size, + "output_mtime_ns": output_stat.st_mtime_ns, + "static_sha256": _package_static_digest( + output, metadata_offset, world_config_value_offset + ), + "content_sha256": fingerprint.digest, + "material_count": material_count, + "texture_count": texture_count, + "visual_vertex_count": counts[2], + "visual_index_count": counts[3], + "collision_triangle_count": fingerprint.collision_triangles, + "grind_rail_count": fingerprint.grind_rails, + "npc_route_count": fingerprint.npc_routes, + "hinged_door_count": fingerprint.hinged_doors, + "local_light_count": fingerprint.local_lights, + "package_counts": list(counts), + } + if world_config_value_offset is not None: + manifest["world_config_value_offset"] = world_config_value_offset + path = _cache_manifest_path(output) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def _refresh_manifest_file_state(output: Path, manifest: dict) -> None: + output_stat = output.stat() + manifest["output_length"] = output_stat.st_size + manifest["output_mtime_ns"] = output_stat.st_mtime_ns + path = _cache_manifest_path(output) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def _manifest_matches_package(output: Path, manifest: dict) -> bool: + if ( + manifest.get("schema") != CACHE_SCHEMA + or not output.is_file() + or output.stat().st_size != manifest.get("output_length") + ): + return False + try: + package_name, metadata_offset, counts = _read_package_header(output) + except (OSError, UnicodeError, ValueError, struct.error): + return False + if ( + package_name != manifest.get("package_name") + or list(counts) != manifest.get("package_counts") + ): + return False + # The exporter records file identity after every metadata patch. An + # unchanged size/mtime pair means the already-verified static payload has + # not been touched, so rereading a 1.2 GiB package would add no value. + if output.stat().st_mtime_ns == manifest.get("output_mtime_ns"): + return True + return ( + _package_static_digest( + output, + metadata_offset, + manifest.get("world_config_value_offset"), + ) + == manifest.get("static_sha256") + ) + + +def _to_runtime(value) -> tuple[float, float, float]: + return float(value.x), float(value.z), float(-value.y) + + +def _stable_object_id(name: str) -> int: + """Return a deterministic non-zero FNV-1a ID for one Blender object.""" + value = 2166136261 + for byte in name.encode("utf-8"): + value ^= byte + value = (value * 16777619) & 0xFFFFFFFF + return value or 1 + + +def _box3d_properties( + source_object: bpy.types.Object, +) -> tuple: + physics_name = str(source_object.get("ow_physics_type", "STATIC")) + physics_type = { + "STATIC": 0, + "PRESENTATION_ONLY": 0, + "BOX3D_STATIC": 1, + "BOX3D_DYNAMIC": 2, + }.get(physics_name) + if physics_type is None: + # Hinged doors are exported through their existing dedicated runtime + # path and never become MOBJ records. + if physics_name == "HINGED_DOOR": + return ( + 0, 0, 100.0, 0.55, 0.05, 0.05, 0.15, 1.0, True, True, + 0, 2.5, 0.45, 0.08, 1.0, + ) + raise ValueError( + f"object {source_object.name!r} has unknown physics mode " + f"{physics_name!r}" + ) + + shape_name = str( + source_object.get("ow_box3d_collision_shape", "BOX") + ) + collision_shape = { + "BOX": 0, + "SPHERE": 1, + "CONVEX_HULL": 2, + }.get(shape_name) + if collision_shape is None: + raise ValueError( + f"object {source_object.name!r} has unknown Box3D collision " + f"shape {shape_name!r}" + ) + + values = ( + float(source_object.get("ow_box3d_density", 100.0)), + float(source_object.get("ow_box3d_friction", 0.55)), + float(source_object.get("ow_box3d_restitution", 0.05)), + float(source_object.get("ow_box3d_linear_damping", 0.05)), + float(source_object.get("ow_box3d_angular_damping", 0.15)), + float(source_object.get("ow_box3d_gravity_scale", 1.0)), + ) + labels_and_ranges = ( + ("density", values[0], 0.001, 100000.0), + ("friction", values[1], 0.0, 2.0), + ("restitution", values[2], 0.0, 1.0), + ("linear damping", values[3], 0.0, 100.0), + ("angular damping", values[4], 0.0, 100.0), + ("gravity scale", values[5], -10.0, 10.0), + ) + for label, value, minimum, maximum in labels_and_ranges: + if not math.isfinite(value) or not minimum <= value <= maximum: + raise ValueError( + f"object {source_object.name!r} Box3D {label} must be " + f"between {minimum:g} and {maximum:g}" + ) + break_values = ( + int(source_object.get("ow_box3d_break_group", 0)), + float(source_object.get("ow_box3d_break_speed_threshold", 2.5)), + float(source_object.get("ow_box3d_break_impulse_scale", 0.45)), + float(source_object.get("ow_box3d_break_angular_impulse", 0.08)), + float(source_object.get("ow_box3d_break_gravity_scale", 1.0)), + ) + if break_values[0] < 0 or break_values[0] > 0xFFFFFFFF: + raise ValueError( + f"object {source_object.name!r} break group is out of range" + ) + break_ranges = ( + ("break speed", break_values[1], 0.1, 30.0), + ("break impulse", break_values[2], 0.0, 10.0), + ("break spin", break_values[3], 0.0, 10.0), + ("released gravity", break_values[4], 0.0, 4.0), + ) + for label, value, minimum, maximum in break_ranges: + if not math.isfinite(value) or not minimum <= value <= maximum: + raise ValueError( + f"object {source_object.name!r} Box3D {label} must be " + f"between {minimum:g} and {maximum:g}" + ) + if break_values[0] != 0 and physics_type != 2: + raise ValueError( + f"object {source_object.name!r} must be Box3D Dynamic to break" + ) + return ( + physics_type, + collision_shape, + *values, + bool(source_object.get("ow_box3d_enable_sleep", True)), + bool(source_object.get("ow_box3d_initially_awake", True)), + *break_values, + ) + + +def _pack_snorm8(value: float) -> int: + return max(-127, min(127, round(max(-1.0, min(1.0, value)) * 127.0))) + + +def _pack_tangent_frame( + binormal: tuple[float, float, float] | Vector, + handedness: float, +) -> tuple[int, int, int, int]: + return ( + _pack_snorm8(float(binormal[0])), + _pack_snorm8(float(binormal[1])), + _pack_snorm8(float(binormal[2])), + _pack_snorm8(handedness), + ) + + +def _export_local_lights() -> list[ExportLocalLight]: + result: list[ExportLocalLight] = [] + type_ids = {"POINT": 0, "SPOT": 1, "AREA": 2} + scene_eevee = getattr(bpy.context.scene, "eevee", None) + light_threshold = max( + 1.0e-16, + float(getattr(scene_eevee, "light_threshold", 0.01)), + ) + for obj in _visible_local_light_objects(): + light = obj.data + forward = obj.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0)) + runtime_forward = Vector(_to_runtime(forward)) + if runtime_forward.length <= 1.0e-6: + raise ValueError(f"light {obj.name!r} has an invalid direction") + runtime_forward.normalize() + + if light.type == "AREA": + source_radius = 0.5 * max( + float(getattr(light, "size", 0.0)), + float(getattr(light, "size_y", 0.0)), + 0.02, + ) + else: + source_radius = max(float(light.shadow_soft_size), 0.01) + if bool(getattr(light, "use_custom_distance", False)): + influence_radius = float(light.cutoff_distance) + else: + # Match Eevee's automatic influence radius. Blender's + # cutoff_distance remains at an inactive 40 m default when + # Custom Distance is disabled; exporting that value made every + # indoor light overlap neighbouring rooms. + maximum_power = ( + max(float(value) for value in light.color) + * abs(float(light.energy) / 100.0) + * max( + float(getattr(light, "diffuse_factor", 1.0)), + float(getattr(light, "specular_factor", 1.0)), + float(getattr(light, "volume_factor", 1.0)), + ) + ) + influence_radius = math.sqrt( + maximum_power / light_threshold + ) + influence_radius = max( + influence_radius, source_radius + 0.01 + ) + + inner_cosine = 1.0 + outer_cosine = 1.0 + if light.type == "SPOT": + outer_half_angle = max( + 0.001, min(math.pi * 0.5, float(light.spot_size) * 0.5) + ) + inner_half_angle = outer_half_angle * ( + 1.0 - max(0.0, min(1.0, float(light.spot_blend))) + ) + inner_cosine = math.cos(inner_half_angle) + outer_cosine = math.cos(outer_half_angle) + + result.append( + ExportLocalLight( + name=obj.name_full, + light_type=type_ids[light.type], + position=_to_runtime(obj.matrix_world.translation), + direction=tuple(float(value) for value in runtime_forward), + color=tuple(float(value) for value in light.color), + # Eevee derives local-light influence from power / 100 and + # evaluates inverse-square falloff. Keep the same power scale + # in the package; the shader applies Blender's attenuation. + intensity=max(0.001, float(light.energy) * 0.01), + influence_radius=influence_radius, + source_radius=source_radius, + spot_inner_cosine=inner_cosine, + spot_outer_cosine=outer_cosine, + ) + ) + return result + + +def _image_rgba8( + image: bpy.types.Image, + *, + lightmap: bool = False, + lightmap_encoding: str = "", +) -> bytes: + retail_encoded = ( + lightmap_encoding == "skate3_retail_sqrt_linear_over_4" + ) + if lightmap_encoding and not retail_encoded: + raise ValueError( + f"image {image.name!r} uses unsupported lightmap encoding " + f"{lightmap_encoding!r}" + ) + expected = image.size[0] * image.size[1] * 4 + if numpy is not None: + # Blender bundles NumPy and foreach_get writes directly into its + # contiguous storage. Vectorizing clamp/transfer/quantization avoids + # billions of Python scalar operations on large imported maps. + values = numpy.empty(expected, dtype=numpy.float32) + image.pixels.foreach_get(values) + values = numpy.nan_to_num( + values, nan=0.0, posinf=1.0, neginf=0.0, copy=False + ) + pixels = values.reshape((-1, 4)) + if lightmap and not retail_encoded: + pixels[:, :3] = numpy.sqrt( + numpy.clip(pixels[:, :3], 0.0, 4.0) * 0.25 + ) + pixels[:, 3] = numpy.clip(pixels[:, 3], 0.0, 1.0) + else: + numpy.clip(pixels, 0.0, 1.0, out=pixels) + # Python round() and numpy.rint() both use ties-to-even, preserving + # the byte-exact exporter contract. + return numpy.rint(pixels * 255.0).astype(numpy.uint8).tobytes() + values = array("f", [0.0]) * expected + image.pixels.foreach_get(values) + result = bytearray(expected) + for index, value in enumerate(values): + channel = index & 3 + linear = max(0.0, float(value)) + if lightmap and not retail_encoded and channel != 3: + # SKATE v1 lightmaps use sqrt(linear / 4). This preserves dark + # indirect energy that direct linear UNORM8 quantization erased, + # while retaining headroom up to 4.0 for bright colour bounce. + encoded = math.sqrt(min(linear, 4.0) * 0.25) + else: + encoded = min(linear, 1.0) + result[index] = max(0, min(255, round(encoded * 255.0))) + return bytes(result) + + +def _has_nonblank_rgb(rgba8: bytes) -> bool: + return any( + rgba8[index] or rgba8[index + 1] or rgba8[index + 2] + for index in range(0, len(rgba8), 4) + ) + + +def _collection(name: str) -> bpy.types.Collection: + collection = bpy.data.collections.get(name) + if collection is None: + raise ValueError(f"required Blender collection is missing: {name}") + return collection + + +def _objects_from_collections(*names: str) -> list[bpy.types.Object]: + result: list[bpy.types.Object] = [] + seen: set[int] = set() + for name in names: + collection = bpy.data.collections.get(name) + if collection is None: + continue + for obj in collection.all_objects: + identity = obj.as_pointer() + if identity not in seen: + seen.add(identity) + result.append(obj) + return result + + +def _spawn_heading(spawn: bpy.types.Object) -> float: + if spawn.type == "MESH": + return float(spawn.matrix_world.to_euler("XYZ").z) + return float(spawn.get("ow_heading_radians", 0.0)) + + +def _is_helper_object(obj: bpy.types.Object) -> bool: + """Reject authoring references even when an old add-on linked them.""" + identity = f"{obj.name} {getattr(obj.data, 'name', '')}".lower() + return any(marker in identity for marker in _HELPER_OBJECT_MARKERS) + + +def _used_export_materials( + visual_objects: list[bpy.types.Object], + collision_objects: list[bpy.types.Object], +) -> list[bpy.types.Material]: + result: list[bpy.types.Material] = [] + seen: set[int] = set() + for obj in [*visual_objects, *collision_objects]: + if obj.type != "MESH": + continue + for slot in obj.material_slots: + material = slot.material + if material is None: + continue + identity = material.as_pointer() + if identity not in seen: + result.append(material) + seen.add(identity) + if not result: + raise ValueError("export groups do not reference any materials") + return result + + +def _object_material_tint( + obj: bpy.types.Object, + material: bpy.types.Material, +) -> tuple[float, float, float]: + if not bool(material.get("ow_uses_object_color", False)): + return 1.0, 1.0, 1.0 + return tuple( + round(max(0.0, min(1.0, float(obj.color[index]))), 6) + for index in range(3) + ) + + +def _used_export_material_variants( + visual_objects: list[bpy.types.Object], + collision_objects: list[bpy.types.Object], +) -> list[ + tuple[bpy.types.Material, tuple[float, float, float]] +]: + result: list[ + tuple[bpy.types.Material, tuple[float, float, float]] + ] = [] + seen: set[tuple[int, tuple[float, float, float]]] = set() + for obj in visual_objects: + if obj.type != "MESH": + continue + for slot in obj.material_slots: + material = slot.material + if material is None: + continue + tint = _object_material_tint(obj, material) + key = material.as_pointer(), tint + if key not in seen: + seen.add(key) + result.append((material, tint)) + for obj in collision_objects: + if obj.type != "MESH": + continue + for slot in obj.material_slots: + material = slot.material + if material is None: + continue + existing = next( + ( + variant + for variant in result + if variant[0] == material + ), + None, + ) + if existing is not None: + continue + tint = (1.0, 1.0, 1.0) + seen.add((material.as_pointer(), tint)) + result.append((material, tint)) + if not result: + raise ValueError("export groups do not reference any materials") + return result + + +def _json_object_property( + owner, + property_name: str, +) -> dict[str, object]: + value = owner.get(property_name, "") + if not value: + return {} + if isinstance(value, str): + parsed = json.loads(value) + else: + parsed = dict(value) + if not isinstance(parsed, dict): + raise ValueError( + f"{owner.name!r} property {property_name!r} must contain a JSON " + "object" + ) + return {str(key): item for key, item in parsed.items()} + + +def _parse_retail_integer(value: object, bits: int) -> int: + if value in (None, ""): + return 0 + parsed = int(str(value), 0) + maximum = (1 << bits) - 1 + if parsed < 0 or parsed > maximum: + raise ValueError(f"retail integer {value!r} exceeds u{bits}") + return parsed + + +def _retail_shader_family(shader_name: str) -> int: + shader = shader_name.lower() + if shader.startswith("environment.reflective_simple"): + return 6 + if shader.startswith("environment.reflective_trans"): + return 13 + if shader.startswith("environment.reflective"): + return 5 + if shader.startswith("environment.decal_tileable"): + return 4 + if shader.startswith("environment.decal"): + return 3 + if shader.startswith("environment.default"): + return 1 + if shader.startswith("environmentsimple.alphatest"): + return 7 + if shader.startswith("environmentsimple.diffuse"): + return 8 + if shader.startswith("environmentsimple.default"): + return 2 + if shader.startswith("tree.default"): + return 9 + if shader.startswith("animated.tree"): + return 10 + if shader.startswith("proxyworld."): + return 11 + if shader.startswith("incandescent.backlituvscroll"): + return 14 + if shader.startswith("incandescent.default"): + return 12 + if shader.startswith("water.flowing"): + return 30 + if shader.startswith("ocean.default"): + return 31 + if shader.startswith("ocean.reflection"): + return 32 + if shader.startswith("sky."): + return 40 + return 0 + + +def _retail_render_flags(shader_name: str, alpha_mode: int) -> int: + shader = shader_name.lower() + flags = 0 + if alpha_mode == 1: + flags |= 1 + elif alpha_mode == 2: + flags |= 2 + if ( + shader.startswith(("tree.", "animated.tree")) + or "alphatest" in shader + ): + flags |= 1 | 4 + if shader.startswith("sky."): + flags |= 8 + if shader.startswith("environment.decal"): + flags |= 16 + if shader.startswith("environment.decal_tileable"): + flags |= 32 + if shader.startswith(("water.", "ocean.")): + flags |= 64 + return flags + + +def _effective_alpha_mode(material: bpy.types.Material) -> int: + authored_mode = _bounded_int(material, "ow_alpha_mode", 0, 2) + if authored_mode != 0 or bool(material.get("ow_force_opaque", False)): + return authored_mode + + material_name = material.name.casefold() + image_name = str(material.get("ow_albedo_image", "")) + image_stem = image_name.casefold().rsplit(".", 1)[0] + alpha_named = ( + material_name.endswith("_a") + or image_stem.endswith("_a") + or "alpha" in material_name + ) + image = bpy.data.images.get(image_name) + if alpha_named and image is not None and image.channels >= 4: + return 1 + return authored_mode + + +def _retail_material_data( + material: bpy.types.Material, + image_ids: dict[int, int], +) -> tuple[ + str, + int, + int, + int, + int, + int, + list[tuple[str, int, int, int, int]], + list[tuple[str, list[str]]], + str, +]: + shader_name = str( + material.get( + "skate3_shader_name", + material.get("ow_retail_shader_name", ""), + ) + ) + if not shader_name: + return "", 0, 0, 0, 0, -1, [], [], "" + + texture_ids = _json_object_property( + material, "skate3_retail_texture_ids" + ) + bindings: list[tuple[str, int, int, int, int]] = [] + tileable_decal = shader_name.lower().startswith( + "environment.decal_tileable" + ) + for semantic, source_id in sorted(texture_ids.items()): + image = bpy.data.images.get(str(source_id).lower()) + if image is None: + raise ValueError( + f"retail material {material.name!r} references missing " + f"{semantic} image {source_id!r}" + ) + texture_id = image_ids.get(image.as_pointer(), 0) + if texture_id == 0: + raise ValueError( + f"retail image {image.name!r} was not collected for export" + ) + uv_set = 1 if semantic in { + "lightmap", "chromaticity", "alpha" + } else ( + 2 if semantic == "decal" else 0 + ) + clamp = semantic in {"lightmap", "chromaticity"} or ( + semantic == "decal" and not tileable_decal + ) + address = 1 if clamp else 0 + bindings.append( + (semantic, texture_id, uv_set, address, address) + ) + + raw_parameters = _json_object_property( + material, "skate3_retail_parameters" + ) + parameters: list[tuple[str, list[str]]] = [] + for name, values in raw_parameters.items(): + if isinstance(values, (list, tuple)): + encoded_values = [str(value) for value in values] + else: + encoded_values = [str(values)] + if encoded_values: + parameters.append((name, encoded_values)) + parameters.sort(key=lambda item: item[0]) + + alpha_mode = _effective_alpha_mode(material) + source = _json_object_property( + material, "skate3_retail_source" + ) + return ( + shader_name, + _retail_shader_family(shader_name), + _retail_render_flags(shader_name, alpha_mode), + _parse_retail_integer( + material.get("skate3_retail_material_guid", ""), 64 + ), + _parse_retail_integer( + material.get("skate3_retail_material_handle", ""), 32 + ), + int(material.get("skate3_retail_material_group_index", -1)), + bindings, + parameters, + json.dumps( + source, sort_keys=True, separators=(",", ":") + ) if source else "", + ) + + +def _is_placeholder_image_name(name: str) -> bool: + return name.strip().casefold().rsplit(".", 1)[0] in { + "none", + "null", + } + + +def _referenced_images( + materials: list[bpy.types.Material], +) -> tuple[list[bpy.types.Image], dict[int, int]]: + images: list[bpy.types.Image] = [] + ids: dict[int, int] = {} + for material in materials: + for property_name in ( + "ow_albedo_image", + "ow_secondary_albedo_image", + "ow_blend_mask_image", + "ow_lightmap_image", + "ow_normal_image", + "ow_orm_image", + "ow_emissive_image", + ): + image_name = str(material.get(property_name, "")) + if not image_name or _is_placeholder_image_name(image_name): + continue + image = bpy.data.images.get(image_name) + if image is None: + raise ValueError( + f"material {material.name!r} references missing image " + f"{image_name!r}" + ) + identity = image.as_pointer() + if identity not in ids: + ids[identity] = len(images) + 1 + images.append(image) + for source_id in _json_object_property( + material, "skate3_retail_texture_ids" + ).values(): + source_name = str(source_id).lower() + if _is_placeholder_image_name(source_name): + continue + image = bpy.data.images.get(source_name) + if image is None: + raise ValueError( + f"material {material.name!r} references missing retail " + f"image {source_id!r}" + ) + identity = image.as_pointer() + if identity not in ids: + ids[identity] = len(images) + 1 + images.append(image) + return images, ids + + +def _texture_id( + material: bpy.types.Material, + property_name: str, + image_ids: dict[int, int], +) -> int: + image = bpy.data.images.get(str(material.get(property_name, ""))) + return ( + image_ids.get(image.as_pointer(), 0) + if image is not None + else 0 + ) + + +def _bounded_int( + material: bpy.types.Material, + property_name: str, + default: int, + maximum: int, +) -> int: + value = int(material.get(property_name, default)) + if value < 0 or value > maximum: + raise ValueError( + f"material {material.name!r} has invalid {property_name}={value}" + ) + return value + + +def _material_color(material: bpy.types.Material) -> tuple[float, float, float]: + authored = material.get("ow_display_color") + if authored is not None and len(authored) >= 3: + return tuple(float(authored[index]) for index in range(3)) + return tuple(float(material.diffuse_color[index]) for index in range(3)) + + +def _presentation_depth_layer(material: bpy.types.Material) -> int: + authored = material.get("ow_depth_layer") + if authored is not None: + value = int(authored) + if value < 0 or value > 3: + raise ValueError( + f"material {material.name!r} has invalid ow_depth_layer={value}" + ) + return value + alpha_mode = _effective_alpha_mode(material) + if alpha_mode == 2: + return 3 + lower_name = material.name.casefold() + overlay_tokens = ( + "sign", + "_sgn", + "sgn_", + "sgns", + "poster", + "billboard", + "adbord", + "advert", + "banner", + "logo", + "decal", + "graffiti", + "sticker", + "plaque", + "letter", + "neon", + "marking", + "videowall", + "vwall", + "branding", + ) + if any(token in lower_name for token in overlay_tokens): + return 2 + return 1 if alpha_mode == 1 else 0 + + +def _duplicate_visual_surface_keep_mask( + mesh: bpy.types.Mesh, + triangle_vertices, + triangle_polygons, + source_positions, +): + """Discard redundant dense copies of the same authored visual surface. + + Some retail rips contain both a conventional static billboard quad and a + densely tessellated video-wall copy in separate material slots that point + at the same material. Rendering both produces persistent triangle-shaped + depth fighting. This detects only geometrically coincident connected + surfaces with the same material name, retaining the simpler copy. + """ + triangle_count = len(triangle_polygons) + keep = numpy.ones(triangle_count, dtype=numpy.bool_) + if triangle_count < 2: + return keep + + material_names = [ + material.name if material is not None else None + for material in mesh.materials + ] + slots_by_name: dict[str, list[int]] = {} + for slot, name in enumerate(material_names): + if name is not None: + slots_by_name.setdefault(name, []).append(slot) + duplicated_slots = { + slot + for slots in slots_by_name.values() + if len(slots) > 1 + for slot in slots + } + if not duplicated_slots: + return keep + + polygon_materials = numpy.empty( + len(mesh.polygons), dtype=numpy.int32 + ) + mesh.polygons.foreach_get("material_index", polygon_materials) + triangle_materials = polygon_materials[triangle_polygons] + candidate_indices = numpy.flatnonzero( + numpy.isin( + triangle_materials, + numpy.fromiter(duplicated_slots, dtype=numpy.int32), + ) + ) + if len(candidate_indices) < 2: + return keep + + parent = {int(index): int(index) for index in candidate_indices} + + def find(index: int) -> int: + root = index + while parent[root] != root: + root = parent[root] + while parent[index] != index: + next_index = parent[index] + parent[index] = root + index = next_index + return root + + def union(left: int, right: int) -> None: + left_root = find(left) + right_root = find(right) + if left_root != right_root: + parent[right_root] = left_root + + first_triangle_by_slot_vertex: dict[tuple[int, int], int] = {} + for index_value in candidate_indices: + index = int(index_value) + slot = int(triangle_materials[index]) + for vertex_value in triangle_vertices[index]: + key = (slot, int(vertex_value)) + previous = first_triangle_by_slot_vertex.get(key) + if previous is None: + first_triangle_by_slot_vertex[key] = index + else: + union(index, previous) + + component_indices: dict[int, list[int]] = {} + for index_value in candidate_indices: + index = int(index_value) + component_indices.setdefault(find(index), []).append(index) + + components_by_name: dict[str, list[dict]] = {} + for indices in component_indices.values(): + slot = int(triangle_materials[indices[0]]) + points = source_positions[triangle_vertices[indices]].astype( + numpy.float64, copy=False + ) + edge_a = points[:, 1] - points[:, 0] + edge_b = points[:, 2] - points[:, 0] + crosses = numpy.cross(edge_a, edge_b) + double_areas = numpy.linalg.norm(crosses, axis=1) + area = float(double_areas.sum() * 0.5) + if area <= 1.0e-10: + continue + centroids = points.mean(axis=1) + centroid = ( + centroids * double_areas[:, None] + ).sum(axis=0) / double_areas.sum() + normal_sum = crosses.sum(axis=0) + normal_length = float(numpy.linalg.norm(normal_sum)) + if normal_length <= 1.0e-10: + continue + flattened = points.reshape((-1, 3)) + minimum = flattened.min(axis=0) + maximum = flattened.max(axis=0) + components_by_name.setdefault(material_names[slot], []).append( + { + "indices": indices, + "slot": slot, + "area": area, + "centroid": centroid, + "normal": normal_sum / normal_length, + "minimum": minimum, + "maximum": maximum, + "extent": float((maximum - minimum).max()), + } + ) + + removed = set() + for components in components_by_name.values(): + for left_index, left in enumerate(components): + if left_index in removed: + continue + for right_index in range(left_index + 1, len(components)): + if right_index in removed: + continue + right = components[right_index] + if left["slot"] == right["slot"]: + continue + scale = max(left["extent"], right["extent"], 1.0) + position_tolerance = max(0.030, scale * 2.0e-4) + if ( + numpy.max( + numpy.abs(left["minimum"] - right["minimum"]) + ) + > position_tolerance + or numpy.max( + numpy.abs(left["maximum"] - right["maximum"]) + ) + > position_tolerance + or abs(left["area"] - right["area"]) + > max(0.005, max(left["area"], right["area"]) * 5.0e-4) + or numpy.dot(left["normal"], right["normal"]) < 0.999 + ): + continue + left_key = (len(left["indices"]), left["slot"]) + right_key = (len(right["indices"]), right["slot"]) + discard_index = ( + right_index if left_key <= right_key else left_index + ) + discard = components[discard_index] + keep[discard["indices"]] = False + removed.add(discard_index) + if discard_index == left_index: + break + return keep + + +def _blender_material_extension( + materials: list[ExportMaterial], +) -> bytes: + payload = bytearray(struct.pack(" bpy.types.MeshUVLoopLayer: + if secondary and str(material.get("skate3_shader_name", "")): + return mesh.uv_layers.get("Decal") or mesh.uv_layers["UVMap"] + if secondary: + uv_name = str( + material.get("ow_blend_mask_uv_map", "") + or material.get("ow_secondary_uv_map", "") + or material.get("ow_uv_map", "") + ) + else: + uv_name = str(material.get("ow_uv_map", "")) + return ( + mesh.uv_layers.get(uv_name) + if uv_name + else None + ) or mesh.uv_layers["UVMap"] + + +def _material_uv_transform( + material: bpy.types.Material, + *, + secondary: bool = False, +) -> tuple[float, float, float, float, float]: + if secondary: + value = ( + material.get("ow_blend_mask_uv_transform") + if str(material.get("ow_blend_mask_image", "")) + else None + ) or material.get("ow_secondary_uv_transform") + else: + value = material.get("ow_uv_transform") + if value is None or len(value) != 5: + return 1.0, 1.0, 0.0, 0.0, 0.0 + result = tuple(float(component) for component in value) + if not all(math.isfinite(component) for component in result): + raise ValueError( + f"material {material.name!r} has a non-finite UV transform" + ) + return result + + +def _transform_uv(uv, transform): + scale_u, scale_v, rotation, translate_u, translate_v = transform + source_u = float(uv[0]) * scale_u + source_v = float(uv[1]) * scale_v + cosine = math.cos(rotation) + sine = math.sin(rotation) + return ( + source_u * cosine - source_v * sine + translate_u, + source_u * sine + source_v * cosine + translate_v, + ) + + +def _export_visual_geometry( + visual_objects: list[bpy.types.Object], + material_variant_ids: dict[ + tuple[str, tuple[float, float, float]], int + ], + progress: ProgressCallback | None = None, +) -> PackedVisualGeometry: + depsgraph = bpy.context.evaluated_depsgraph_get() + vertex_chunks: list[bytes] = [] + index_chunks: list[bytes] = [] + vertex_count = 0 + index_count = 0 + objects: list[ExportMapObject] = [] + mesh_objects = [ + obj for obj in visual_objects if obj.type == "MESH" + ] + weights = [ + max(1, len(obj.data.polygons)) for obj in mesh_objects + ] + total_weight = max(1, sum(weights)) + completed_weight = 0 + packed_vertex = struct.Struct("<3f3f2f2fI2f4b") + + for object_index, (source_object, object_weight) in enumerate( + zip(mesh_objects, weights, strict=True), + start=1, + ): + if source_object.type != "MESH": + continue + object_first_index = index_count + mesh, evaluated = _mesh_for_export( + source_object, + depsgraph, + preserve_all_data_layers=True, + ) + try: + mesh.calc_loop_triangles() + uv0, uv1 = _visual_uv_layers(mesh, source_object.name) + loop_count = len(mesh.loops) + if len(uv0.data) != loop_count or len(uv1.data) != loop_count: + raise ValueError( + f"visual mesh {source_object.name!r} has malformed UV " + "layer data" + ) + decal_uv = mesh.uv_layers.get("Decal") or uv0 + if len(decal_uv.data) != loop_count: + raise ValueError( + f"visual mesh {source_object.name!r} has malformed " + "decal UV layer data" + ) + retail_frame_attributes = _retail_world_frame_attributes(mesh) + tangents_available = retail_frame_attributes is None + if tangents_available: + try: + mesh.calc_tangents(uvmap=uv0.name) + except RuntimeError: + tangents_available = False + # Blender 5 can replace implicitly shared UV storage while + # calculating tangents. Reacquire every layer so the references + # below cannot alias the active UVMap after that mutation. + uv0, uv1 = _visual_uv_layers(mesh, source_object.name) + decal_uv = mesh.uv_layers.get("Decal") or uv0 + if decal_uv is None: + raise ValueError( + f"visual mesh {source_object.name!r} lost an export UV " + "layer while calculating tangents" + ) + + if numpy is not None: + triangle_count = len(mesh.loop_triangles) + corner_count = triangle_count * 3 + triangle_loops = numpy.empty( + corner_count, dtype=numpy.int32 + ) + triangle_polygons = numpy.empty( + triangle_count, dtype=numpy.int32 + ) + mesh.loop_triangles.foreach_get( + "loops", triangle_loops + ) + mesh.loop_triangles.foreach_get( + "polygon_index", triangle_polygons + ) + + loop_vertices = numpy.empty( + loop_count, dtype=numpy.int32 + ) + mesh.loops.foreach_get( + "vertex_index", loop_vertices + ) + if retail_frame_attributes is None: + loop_normals = numpy.empty( + loop_count * 3, dtype=numpy.float32 + ) + mesh.loops.foreach_get("normal", loop_normals) + loop_normals = loop_normals.reshape((-1, 3)) + else: + retail_normal_attribute = retail_frame_attributes[0] + retail_point_normals = numpy.empty( + len(mesh.vertices) * 3, + dtype=numpy.float32, + ) + retail_normal_attribute.data.foreach_get( + "vector", retail_point_normals + ) + retail_point_normals = retail_point_normals.reshape( + (-1, 3) + ) + + source_positions = numpy.empty( + len(mesh.vertices) * 3, dtype=numpy.float32 + ) + mesh.vertices.foreach_get("co", source_positions) + source_positions = source_positions.reshape((-1, 3)) + + base_uvs = numpy.empty( + loop_count * 2, dtype=numpy.float32 + ) + light_uvs = numpy.empty( + loop_count * 2, dtype=numpy.float32 + ) + decal_uvs = numpy.empty( + loop_count * 2, dtype=numpy.float32 + ) + uv0.data.foreach_get("uv", base_uvs) + uv1.data.foreach_get("uv", light_uvs) + decal_uv.data.foreach_get("uv", decal_uvs) + base_uvs = base_uvs.reshape((-1, 2)) + light_uvs = light_uvs.reshape((-1, 2)) + decal_uvs = decal_uvs.reshape((-1, 2)) + + polygon_materials = numpy.empty( + len(mesh.polygons), dtype=numpy.int32 + ) + mesh.polygons.foreach_get( + "material_index", polygon_materials + ) + used_material_indices = numpy.unique( + polygon_materials[triangle_polygons] + ) + if ( + used_material_indices.size + and ( + int(used_material_indices.min()) < 0 + or int(used_material_indices.max()) + >= len(mesh.materials) + ) + ): + raise ValueError( + f"mesh {source_object.name!r} has an invalid " + "material slot" + ) + mesh_material_ids = numpy.zeros( + len(mesh.materials), dtype=numpy.uint32 + ) + for material_index in used_material_indices: + material_index = int(material_index) + material = mesh.materials[material_index] + if material is None: + raise ValueError( + f"mesh {source_object.name!r} has an " + "unexported material" + ) + variant_key = ( + material.name, + _object_material_tint(source_object, material), + ) + if variant_key not in material_variant_ids: + raise ValueError( + f"mesh {source_object.name!r} has an " + "unexported material colour variant" + ) + mesh_material_ids[material_index] = ( + material_variant_ids[variant_key] + ) + + triangle_loops = triangle_loops.reshape((-1, 3)) + triangle_vertices = loop_vertices[triangle_loops] + if not bool( + source_object.get( + "ow_preserve_duplicate_visual_surfaces", False + ) + ): + keep_triangles = _duplicate_visual_surface_keep_mask( + mesh, + triangle_vertices, + triangle_polygons, + source_positions, + ) + removed_triangles = int( + len(keep_triangles) - keep_triangles.sum() + ) + if removed_triangles: + print( + "SKATE visual cleanup:" + f" {source_object.name!r} omitted" + f" {removed_triangles} redundant coincident" + " triangle(s)", + flush=True, + ) + triangle_loops = triangle_loops[keep_triangles] + triangle_polygons = triangle_polygons[keep_triangles] + triangle_vertices = triangle_vertices[keep_triangles] + triangle_count = len(triangle_polygons) + corner_count = triangle_count * 3 + triangle_loops = triangle_loops.reshape(-1) + corner_vertices = triangle_vertices.reshape(-1) + positions = source_positions[corner_vertices].astype( + numpy.float64 + ) + world_matrix = numpy.asarray( + source_object.matrix_world, dtype=numpy.float64 + ) + positions = ( + positions @ world_matrix[:3, :3].T + + world_matrix[:3, 3] + ) + + normal_matrix = numpy.asarray( + source_object.matrix_world + .to_3x3() + .inverted() + .transposed(), + dtype=numpy.float64, + ) + if retail_frame_attributes is None: + normals = ( + loop_normals[triangle_loops].astype(numpy.float64) + @ normal_matrix.T + ) + else: + normals = ( + retail_point_normals[corner_vertices].astype( + numpy.float64 + ) + @ normal_matrix.T + ) + lengths = numpy.linalg.norm(normals, axis=1) + nonzero_normals = lengths > 1.0e-12 + if retail_frame_attributes is None: + normals[nonzero_normals] /= lengths[ + nonzero_normals, None + ] + + runtime_positions = positions[:, (0, 2, 1)].copy() + runtime_positions[:, 2] *= -1.0 + runtime_normals = normals[:, (0, 2, 1)].copy() + runtime_normals[:, 2] *= -1.0 + runtime_binormals = numpy.zeros_like(runtime_normals) + runtime_handedness = numpy.zeros( + corner_count, dtype=numpy.float32 + ) + runtime_linear = numpy.asarray( + ( + (1.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + (0.0, -1.0, 0.0), + ), + dtype=numpy.float64, + ) @ world_matrix[:3, :3] + orientation = ( + -1.0 + if numpy.linalg.det(runtime_linear) < 0.0 + else 1.0 + ) + if retail_frame_attributes is not None: + ( + _retail_normal_attribute, + retail_tangent_attribute, + retail_handedness_attribute, + ) = retail_frame_attributes + retail_point_tangents = numpy.empty( + len(mesh.vertices) * 3, + dtype=numpy.float32, + ) + retail_point_handedness = numpy.empty( + len(mesh.vertices), + dtype=numpy.float32, + ) + retail_tangent_attribute.data.foreach_get( + "vector", retail_point_tangents + ) + retail_handedness_attribute.data.foreach_get( + "value", retail_point_handedness + ) + retail_point_tangents = ( + retail_point_tangents.reshape((-1, 3)) + ) + tangents = ( + retail_point_tangents[corner_vertices].astype( + numpy.float64 + ) + @ world_matrix[:3, :3].T + ) + runtime_tangents = tangents[:, (0, 2, 1)].copy() + runtime_tangents[:, 2] *= -1.0 + runtime_handedness = ( + retail_point_handedness[corner_vertices] * orientation + ).astype(numpy.float32) + source_tangent_lengths = numpy.linalg.norm( + runtime_tangents, axis=1 + ) + runtime_tangents -= runtime_normals * numpy.sum( + runtime_tangents * runtime_normals, + axis=1, + )[:, None] + tangent_lengths = numpy.linalg.norm( + runtime_tangents, axis=1 + ) + valid_tangents = tangent_lengths > numpy.maximum( + 1.0e-12, + source_tangent_lengths * 1.0e-6, + ) + runtime_tangents[valid_tangents] /= tangent_lengths[ + valid_tangents, None + ] + runtime_handedness[~valid_tangents] = 0.0 + runtime_binormals = numpy.cross( + runtime_normals, runtime_tangents + ) * runtime_handedness[:, None] + elif tangents_available: + loop_tangents = numpy.empty( + loop_count * 3, dtype=numpy.float32 + ) + loop_signs = numpy.empty( + loop_count, dtype=numpy.float32 + ) + mesh.loops.foreach_get("tangent", loop_tangents) + mesh.loops.foreach_get( + "bitangent_sign", loop_signs + ) + loop_tangents = loop_tangents.reshape((-1, 3)) + tangents = ( + loop_tangents[triangle_loops].astype(numpy.float64) + @ world_matrix[:3, :3].T + ) + runtime_tangents = tangents[:, (0, 2, 1)].copy() + runtime_tangents[:, 2] *= -1.0 + source_tangent_lengths = numpy.linalg.norm( + runtime_tangents, axis=1 + ) + runtime_tangents -= runtime_normals * numpy.sum( + runtime_tangents * runtime_normals, + axis=1, + )[:, None] + tangent_lengths = numpy.linalg.norm( + runtime_tangents, axis=1 + ) + valid_tangents = tangent_lengths > numpy.maximum( + 1.0e-12, + source_tangent_lengths * 1.0e-6, + ) + runtime_tangents[valid_tangents] /= tangent_lengths[ + valid_tangents, None + ] + runtime_handedness = ( + loop_signs[triangle_loops] * orientation + ).astype(numpy.float32) + runtime_handedness[~valid_tangents] = 0.0 + runtime_binormals = numpy.cross( + runtime_normals, runtime_tangents + ) * runtime_handedness[:, None] + triangle_material_indices = polygon_materials[ + triangle_polygons + ] + corner_material_indices = numpy.repeat( + triangle_material_indices, 3 + ) + corner_materials = mesh_material_ids[ + corner_material_indices + ] + corner_base_uvs = base_uvs[triangle_loops].copy() + corner_secondary_uvs = decal_uvs[ + triangle_loops + ].copy() + uv_array_cache: dict[str, numpy.ndarray] = { + uv0.name: base_uvs, + decal_uv.name: decal_uvs, + } + + def uv_values(layer): + values = uv_array_cache.get(layer.name) + if values is None: + values = numpy.empty( + loop_count * 2, dtype=numpy.float32 + ) + layer.data.foreach_get("uv", values) + values = values.reshape((-1, 2)) + uv_array_cache[layer.name] = values + return values + + for material_index in used_material_indices: + material_index = int(material_index) + material = mesh.materials[material_index] + corner_mask = ( + corner_material_indices == material_index + ) + primary_layer = _material_uv_layer( + mesh, material + ) + secondary_layer = _material_uv_layer( + mesh, material, secondary=True + ) + selected_base = uv_values(primary_layer)[ + triangle_loops[corner_mask] + ].copy() + selected_secondary = uv_values(secondary_layer)[ + triangle_loops[corner_mask] + ].copy() + for selected, transform in ( + ( + selected_base, + _material_uv_transform(material), + ), + ( + selected_secondary, + _material_uv_transform( + material, secondary=True + ), + ), + ): + scale_u, scale_v, rotation, move_u, move_v = ( + transform + ) + source_u = selected[:, 0] * scale_u + source_v = selected[:, 1] * scale_v + cosine = math.cos(rotation) + sine = math.sin(rotation) + selected[:, 0] = ( + source_u * cosine + - source_v * sine + + move_u + ) + selected[:, 1] = ( + source_u * sine + + source_v * cosine + + move_v + ) + corner_base_uvs[corner_mask] = selected_base + corner_secondary_uvs[corner_mask] = ( + selected_secondary + ) + + if not all( + numpy.isfinite(values).all() + for values in ( + runtime_positions, + runtime_normals, + corner_base_uvs, + light_uvs[triangle_loops], + corner_secondary_uvs, + runtime_binormals, + runtime_handedness, + ) + ): + raise ValueError( + f"visual mesh {source_object.name!r} contains " + "non-finite geometry or UV values" + ) + + record_dtype = numpy.dtype( + [ + ("position", " 0xFFFFFFFF: + raise ValueError( + "visual geometry exceeds the SKATE u32 index limit" + ) + indices = inverse.astype( + numpy.dtype("= len(mesh.materials): + raise ValueError( + f"mesh {source_object.name!r} has an invalid " + "material slot" + ) + material = mesh.materials[polygon.material_index] + if material is None: + raise ValueError( + f"mesh {source_object.name!r} has an " + "unexported material" + ) + variant_key = ( + material.name, + _object_material_tint(source_object, material), + ) + if variant_key not in material_variant_ids: + raise ValueError( + f"mesh {source_object.name!r} has an " + "unexported material colour variant" + ) + material_id = material_variant_ids[variant_key] + material_uv = _material_uv_layer( + mesh, material + ) + material_secondary_uv = _material_uv_layer( + mesh, material, secondary=True + ) + for loop_index in triangle.loops: + loop = mesh.loops[loop_index] + point = ( + source_object.matrix_world + @ mesh.vertices[loop.vertex_index].co + ) + if retail_frame_attributes is None: + normal = ( + normal_matrix @ loop.normal + ).normalized() + else: + normal = ( + normal_matrix + @ Vector( + retail_frame_attributes[0] + .data[loop.vertex_index] + .vector + ) + ) + base_uv = _transform_uv( + material_uv.data[loop_index].uv, + _material_uv_transform(material), + ) + light_uv = uv1.data[loop_index].uv + decal = _transform_uv( + material_secondary_uv.data[loop_index].uv, + _material_uv_transform( + material, secondary=True + ), + ) + handedness = 0.0 + binormal = Vector((0.0, 0.0, 0.0)) + orientation = ( + 1.0 + if source_object.matrix_world.to_3x3() + .determinant() > 0.0 + else -1.0 + ) + if retail_frame_attributes is not None: + local_binormal = Vector( + retail_frame_attributes[1] + .data[loop.vertex_index] + .vector + ) + binormal = Vector( + _to_runtime( + source_object.matrix_world.to_3x3() + @ local_binormal + ) + ) + handedness = ( + float( + retail_frame_attributes[2] + .data[loop.vertex_index] + .value + ) + * orientation + ) + elif tangents_available: + tangent = ( + source_object.matrix_world.to_3x3() + @ loop.tangent + ) + source_tangent_length = tangent.length + tangent -= normal * normal.dot(tangent) + if tangent.length > max( + 1.0e-12, + source_tangent_length * 1.0e-6, + ): + tangent.normalize() + runtime_normal = Vector( + _to_runtime(normal) + ) + runtime_tangent = Vector( + _to_runtime(tangent) + ) + handedness = ( + float(loop.bitangent_sign) + * orientation + ) + binormal = ( + runtime_normal.cross(runtime_tangent) + * handedness + ) + values = ( + *_to_runtime(point), + *_to_runtime(normal), + float(base_uv[0]), + float(base_uv[1]), + float(light_uv.x), + float(light_uv.y), + ) + retail_values = ( + float(decal[0]), + float(decal[1]), + ) + if not all( + math.isfinite(value) + for value in (*values, *retail_values) + ): + raise ValueError( + f"visual mesh {source_object.name!r} " + "contains non-finite geometry or UV values" + ) + record = packed_vertex.pack( + *values, + material_id, + *retail_values, + *_pack_tangent_frame(binormal, handedness), + ) + local_index = unique_vertices.get(record) + if local_index is None: + local_index = len(unique_vertices) + unique_vertices[record] = local_index + vertex_chunk.extend(record) + index_values.append(vertex_count + local_index) + index_count += 1 + if sys.byteorder != "little": + index_values.byteswap() + vertex_chunks.append(bytes(vertex_chunk)) + index_chunks.append(index_values.tobytes()) + vertex_count += len(unique_vertices) + finally: + if evaluated is not None: + evaluated.to_mesh_clear() + + object_index_count = index_count - object_first_index + if object_index_count: + ( + physics_type, + collision_shape, + density, + friction, + restitution, + linear_damping, + angular_damping, + gravity_scale, + enable_sleep, + initially_awake, + break_group, + break_speed_threshold, + break_impulse_scale, + break_angular_impulse, + break_gravity_scale, + ) = _box3d_properties(source_object) + objects.append( + ExportMapObject( + source_identity=source_object.as_pointer(), + object_id=_stable_object_id(source_object.name_full), + name=source_object.name_full, + origin=_to_runtime( + source_object.matrix_world.translation + ), + first_index=object_first_index, + index_count=object_index_count, + editor_editable=bool( + source_object.get("ow_editor_editable", True) + ), + physics_type=physics_type, + collision_shape=collision_shape, + density=density, + friction=friction, + restitution=restitution, + linear_damping=linear_damping, + angular_damping=angular_damping, + gravity_scale=gravity_scale, + enable_sleep=enable_sleep, + initially_awake=initially_awake, + break_group=break_group, + break_speed_threshold=break_speed_threshold, + break_impulse_scale=break_impulse_scale, + break_angular_impulse=break_angular_impulse, + break_gravity_scale=break_gravity_scale, + ) + ) + completed_weight += object_weight + _report_progress( + progress, + completed_weight / total_weight, + f"Packing visuals ({object_index}/{len(mesh_objects)}): " + f"{source_object.name}", + ) + + if not vertex_count: + raise ValueError("presentation groups contain no exportable triangles") + object_ids = [record.object_id for record in objects] + if len(set(object_ids)) != len(object_ids): + raise ValueError( + "Blender object names produced a stable-ID collision; rename " + "one of the colliding objects" + ) + return PackedVisualGeometry( + vertex_chunks, + index_chunks, + vertex_count, + index_count, + objects, + ) + + +def audit_collision_geometry( + collision_objects: list[bpy.types.Object], + material_name_ids: dict[str, int] | None = None, + progress: ProgressCallback | None = None, + object_ranges: dict[int, tuple[int, int]] | None = None, + include_editor_ownership: bool = True, +) -> tuple[list[tuple], CollisionGeometryAudit]: + depsgraph = bpy.context.evaluated_depsgraph_get() + triangles: list[tuple] = [] + partitioned_triangles: dict[int, list[tuple]] = {} + partitioned_owners: dict[int, bpy.types.Object] = {} + editable_owner_ids = ( + { + _stable_object_id(obj.name_full): obj + for obj in bpy.data.objects + if obj.type == "MESH" + and bool(obj.get("ow_editor_editable", True)) + } + if include_editor_ownership + else {} + ) + triangle_owners: dict[tuple, str] = {} + issues: list[str] = [] + warnings: list[str] = [] + source_triangle_count = 0 + skipped_degenerate = 0 + skipped_duplicates = 0 + surface_id = 1 + mesh_objects = [ + obj for obj in collision_objects if obj.type == "MESH" + ] + for object_index, source_object in enumerate( + mesh_objects, start=1 + ): + object_first_triangle = len(triangles) + _report_progress( + progress, + (object_index - 1) / max(1, len(mesh_objects)), + f"Auditing collision ({object_index}/" + f"{len(mesh_objects)}): {source_object.name}", + ) + material_name = str(source_object.get("ow_material", "")) + blender_material = bpy.data.materials.get(material_name) + if ( + blender_material is not None + and not bool( + blender_material.get("ow_collision_enabled", True) + ) + ): + continue + if ( + material_name_ids is not None + and material_name not in material_name_ids + ): + issues.append( + f"{source_object.name}: collision material " + f"{material_name!r} is not exported by a presentation group." + ) + continue + material_id = ( + material_name_ids[material_name] + if material_name_ids is not None + else 0 + ) + preserve_retail_codes = bool( + source_object.get("ow_preserve_retail_edge_codes", False) + ) + mesh, evaluated = _mesh_for_export( + source_object, + depsgraph, + preserve_all_data_layers=preserve_retail_codes, + ) + edge_attributes = [] + editor_owner_attribute = ( + mesh.attributes.get(EDITOR_COLLISION_OWNER_ATTRIBUTE) + if include_editor_ownership + else None + ) + if editor_owner_attribute is not None and ( + editor_owner_attribute.domain != "FACE" + or editor_owner_attribute.data_type != "INT" + ): + issues.append( + f"{source_object.name}: editor collision owner attribute " + f"{EDITOR_COLLISION_OWNER_ATTRIBUTE!r} must be a FACE INT." + ) + editor_owner_attribute = None + if preserve_retail_codes: + for attribute_name in RETAIL_EDGE_CODE_ATTRIBUTES: + attribute = mesh.attributes.get(attribute_name) + if ( + attribute is None + or attribute.domain != "FACE" + or attribute.data_type != "INT" + ): + issues.append( + f"{source_object.name}: native retail collision " + f"attribute {attribute_name!r} is missing or invalid." + ) + edge_attributes = [] + break + edge_attributes.append(attribute) + object_degenerate = 0 + object_duplicates = 0 + object_non_finite = 0 + object_wrong_facing = 0 + try: + mesh.calc_loop_triangles() + for triangle in mesh.loop_triangles: + source_triangle_count += 1 + blender_points = [ + source_object.matrix_world @ mesh.vertices[index].co + for index in triangle.vertices + ] + if not all( + math.isfinite(float(component)) + for point in blender_points + for component in point + ): + object_non_finite += 1 + continue + try: + points = [ + tuple( + struct.unpack( + " 255 for code in polygon_codes): + issues.append( + f"{source_object.name}: native retail collision " + "edge code is outside the byte range." + ) + continue + native_edge_codes = tuple( + polygon_codes[(rotation + corner) % 3] + for corner in range(3) + ) + exported_triangle = ( + points[0], + points[1], + points[2], + surface_id, + material_id, + native_edge_codes, + ) + editor_owner = None + if editor_owner_attribute is not None: + owner_id = ( + int( + editor_owner_attribute.data[ + triangle.polygon_index + ].value + ) + & 0xFFFFFFFF + ) + if owner_id != 0: + editor_owner = editable_owner_ids.get(owner_id) + if editor_owner is None: + issues.append( + f"{source_object.name}: collision face " + f"references missing editable object ID " + f"0x{owner_id:08X}." + ) + if editor_owner is None: + triangles.append(exported_triangle) + else: + owner_identity = editor_owner.as_pointer() + partitioned_owners[owner_identity] = editor_owner + partitioned_triangles.setdefault( + owner_identity, [] + ).append(exported_triangle) + finally: + if evaluated is not None: + evaluated.to_mesh_clear() + if object_degenerate: + warnings.append( + f"{source_object.name}: skipped {object_degenerate} " + "zero-area collision triangle(s). No mesh dissolve or UV " + "changes are required." + ) + if object_duplicates: + warnings.append( + f"{source_object.name}: skipped {object_duplicates} " + + ( + "same-wound duplicate collision triangle(s); retained " + "reverse-wound retail partners." + if bool( + source_object.get( + "ow_preserve_opposite_wound_collision", + False, + ) + ) + else "exact or opposite-wound duplicate collision " + "triangle(s)." + ) + ) + if object_non_finite: + issues.append( + f"{source_object.name}: {object_non_finite} collision " + "triangle(s) contain non-finite or float32-out-of-range " + "coordinates." + ) + if object_wrong_facing: + issues.append( + f"{source_object.name}: {object_wrong_facing} triangle(s) " + "face downward or vertically, but this object is marked " + "Rideable Top Surface." + ) + if ( + include_editor_ownership + and object_ranges is not None + and editor_owner_attribute is None + ): + owner_identity = source_object.as_pointer() + owner_name = str( + source_object.get("ow_map_object_owner", "") + ).strip() + if owner_name: + owner = bpy.data.objects.get(owner_name) + if owner is None or owner.type != "MESH": + issues.append( + f"{source_object.name}: editable collision owner " + f"{owner_name!r} is missing or is not a mesh." + ) + else: + owner_identity = owner.as_pointer() + object_triangle_count = len(triangles) - object_first_triangle + previous_range = object_ranges.get(owner_identity) + if previous_range is None: + object_ranges[owner_identity] = ( + object_first_triangle, + object_triangle_count, + ) + elif ( + previous_range[0] + previous_range[1] + == object_first_triangle + ): + object_ranges[owner_identity] = ( + previous_range[0], + previous_range[1] + object_triangle_count, + ) + else: + issues.append( + f"{source_object.name}: collision proxies for editable " + f"owner {owner_name or source_object.name!r} are not " + "contiguous in OW_COLLISION." + ) + surface_id += 1 + for owner_identity in sorted( + partitioned_triangles, + key=lambda identity: partitioned_owners[identity].name_full, + ): + owned = partitioned_triangles[owner_identity] + if not owned: + continue + if object_ranges is not None and owner_identity in object_ranges: + issues.append( + f"{partitioned_owners[owner_identity].name}: collision is " + "owned by both a proxy object and face-level partitions." + ) + continue + first_triangle = len(triangles) + triangles.extend(owned) + if object_ranges is not None: + object_ranges[owner_identity] = ( + first_triangle, + len(owned), + ) + if not triangles: + issues.append("collision groups contain no usable collision triangles.") + audit = CollisionGeometryAudit( + issues=issues, + warnings=warnings, + source_triangles=source_triangle_count, + exported_triangles=len(triangles), + skipped_degenerate=skipped_degenerate, + skipped_duplicates=skipped_duplicates, + ) + _report_progress(progress, 1.0, "Collision audit complete") + return triangles, audit + + +def _export_collision( + collision_objects: list[bpy.types.Object], + material_name_ids: dict[str, int], + export_editable_objects: bool, + progress: ProgressCallback | None = None, +) -> tuple[ + list[tuple], + CollisionGeometryAudit, + dict[int, tuple[int, int]], +]: + global LAST_COLLISION_AUDIT + object_ranges: dict[int, tuple[int, int]] = {} + triangles, audit = audit_collision_geometry( + collision_objects, + material_name_ids, + progress, + object_ranges, + export_editable_objects, + ) + LAST_COLLISION_AUDIT = audit + if audit.issues: + raise CollisionGeometryError(audit.issues, audit.warnings) + for warning in audit.warnings: + print(f"SKATE collision cleanup: {warning}", flush=True) + return triangles, audit, object_ranges + + +def _retail_grind_controls( + payload: bytes, +) -> tuple[tuple[float, float, float], ...]: + values = struct.unpack(">30f", payload) + coefficient_a = values[0:3] + coefficient_b = values[4:7] + coefficient_c = values[8:11] + coefficient_d = values[12:15] + return ( + tuple(coefficient_d), + tuple( + coefficient_d[axis] + coefficient_c[axis] / 3.0 + for axis in range(3) + ), + tuple( + coefficient_d[axis] + + (2.0 * coefficient_c[axis] + coefficient_b[axis]) / 3.0 + for axis in range(3) + ), + tuple( + coefficient_d[axis] + + coefficient_c[axis] + + coefficient_b[axis] + + coefficient_a[axis] + for axis in range(3) + ), + ) + + +def _close_enough( + left: tuple[float, float, float], + right: tuple[float, float, float], + tolerance: float = 2.0e-3, +) -> bool: + return all( + abs(left[axis] - right[axis]) <= tolerance + for axis in range(3) + ) + + +def _export_retail_grind( + obj: bpy.types.Object, +) -> ExportGrindRail: + if len(obj.data.splines) != 1: + raise ValueError( + f"retail grind {obj.name!r} must contain exactly one spline" + ) + spline = obj.data.splines[0] + if spline.type != "BEZIER": + raise ValueError( + f"retail grind {obj.name!r} must remain a Bezier spline" + ) + segment_count = int(obj["skate3_retail_grind_segment_count"]) + actual_segments = ( + len(spline.bezier_points) + if spline.use_cyclic_u + else len(spline.bezier_points) - 1 + ) + if segment_count <= 0 or actual_segments != segment_count: + raise ValueError( + f"retail grind {obj.name!r} segment count changed: " + f"{actual_segments} versus {segment_count}" + ) + payload_hex = str(obj["skate3_retail_grind_segment_payload"]) + try: + payload = bytes.fromhex(payload_hex) + except ValueError as error: + raise ValueError( + f"retail grind {obj.name!r} has invalid native payload" + ) from error + if len(payload) != segment_count * 120: + raise ValueError( + f"retail grind {obj.name!r} native payload size changed" + ) + + points = spline.bezier_points + for segment_index in range(segment_count): + current = points[segment_index] + following = points[(segment_index + 1) % len(points)] + actual_controls = ( + _to_runtime(obj.matrix_world @ current.co), + _to_runtime(obj.matrix_world @ current.handle_right), + _to_runtime(obj.matrix_world @ following.handle_left), + _to_runtime(obj.matrix_world @ following.co), + ) + expected_controls = _retail_grind_controls( + payload[segment_index * 120 : (segment_index + 1) * 120] + ) + if not all( + _close_enough(actual, expected) + for actual, expected in zip( + actual_controls, + expected_controls, + ) + ): + raise ValueError( + f"retail grind {obj.name!r} was edited; exact native " + f"segment {segment_index} no longer matches its Blender curve" + ) + + return ExportGrindRail( + name=obj.name, + closed=bool(spline.use_cyclic_u), + points=[], + retail_spline_id=int( + str(obj["skate3_retail_grind_spline_id"]), + 16, + ), + retail_type_signature=int( + str(obj["skate3_retail_grind_type_signature"]), + 16, + ), + retail_flags=int(obj["skate3_retail_grind_flags"]), + retail_trailing_word=int( + obj["skate3_retail_grind_trailing_word"] + ), + native_segment_payload=payload, + parent_source_identity=( + obj.parent.as_pointer() + if obj.parent is not None and obj.parent.type == "MESH" + else 0 + ), + ) + + +def _export_grinds( + grind_objects: list[bpy.types.Object], +) -> list[ExportGrindRail]: + rails: list[ExportGrindRail] = [] + for obj in grind_objects: + if obj.type != "CURVE": + continue + if bool(obj.get("skate3_retail_grind", False)): + rails.append(_export_retail_grind(obj)) + continue + for spline_index, spline in enumerate(obj.data.splines): + points: list[tuple[float, float, float]] = [] + if spline.type == "POLY": + points = [ + _to_runtime(obj.matrix_world @ Vector(point.co[:3])) + for point in spline.points + ] + elif spline.type == "BEZIER": + points = [ + _to_runtime(obj.matrix_world @ point.co) + for point in spline.bezier_points + ] + if len(points) < 2: + continue + name = obj.name if len(obj.data.splines) == 1 else ( + f"{obj.name}_{spline_index}" + ) + rails.append( + ExportGrindRail( + name=name, + closed=bool(spline.use_cyclic_u), + points=points, + parent_source_identity=( + obj.parent.as_pointer() + if obj.parent is not None + and obj.parent.type == "MESH" + else 0 + ), + ) + ) + return rails + + +def _export_npc_routes( + npc_path_objects: list[bpy.types.Object], +) -> list[tuple]: + routes: list[tuple] = [] + for obj in npc_path_objects: + if obj.type != "CURVE": + continue + skater_count = int(obj.get("ow_npc_skater_count", 1)) + speed = float(obj.get("ow_npc_speed", 5.5)) + spawn_spacing = float(obj.get("ow_npc_spawn_spacing", 3.0)) + if not 1 <= skater_count <= 32: + raise ValueError( + f"NPC path {obj.name!r} skater count must be 1-32" + ) + if not math.isfinite(speed) or not 0.0 < speed <= 30.0: + raise ValueError( + f"NPC path {obj.name!r} speed must be above 0 and at most 30" + ) + if ( + not math.isfinite(spawn_spacing) + or not 0.0 <= spawn_spacing <= 100.0 + ): + raise ValueError( + f"NPC path {obj.name!r} spacing must be 0-100 metres" + ) + for spline_index, spline in enumerate(obj.data.splines): + points: list[tuple[float, float, float]] = [] + if spline.type == "POLY": + points = [ + _to_runtime(obj.matrix_world @ Vector(point.co[:3])) + for point in spline.points + ] + elif spline.type == "BEZIER": + points = [ + _to_runtime(obj.matrix_world @ point.co) + for point in spline.bezier_points + ] + if len(points) < 2: + continue + name = obj.name if len(obj.data.splines) == 1 else ( + f"{obj.name}_{spline_index}" + ) + routes.append( + ( + name, + bool(spline.use_cyclic_u), + skater_count, + speed, + spawn_spacing, + points, + ) + ) + return routes + + +def _door_objects( + visual_objects: list[bpy.types.Object], +) -> list[bpy.types.Object]: + return [ + obj + for obj in visual_objects + if obj.type == "MESH" + and str(obj.get("ow_physics_type", "STATIC")) == "HINGED_DOOR" + ] + + +def _static_visual_objects( + visual_objects: list[bpy.types.Object], +) -> list[bpy.types.Object]: + door_ids = {obj.as_pointer() for obj in _door_objects(visual_objects)} + return [ + obj + for obj in visual_objects + if obj.as_pointer() not in door_ids + ] + + +def _normalized_vector(value, label: str) -> Vector: + vector = Vector(tuple(float(component) for component in value)) + if vector.length <= 1.0e-6: + raise ValueError(f"{label} must be a non-zero vector") + return vector.normalized() + + +def _door_box_collision( + minimum: Vector, + maximum: Vector, + surface_id: int, + material_id: int, +) -> list[tuple]: + corners = [ + ( + maximum.x if index & 1 else minimum.x, + maximum.y if index & 2 else minimum.y, + maximum.z if index & 4 else minimum.z, + ) + for index in range(8) + ] + faces = ( + (0, 2, 3), (0, 3, 1), + (4, 5, 7), (4, 7, 6), + (0, 4, 6), (0, 6, 2), + (1, 3, 7), (1, 7, 5), + (0, 1, 5), (0, 5, 4), + (2, 6, 7), (2, 7, 3), + ) + return [ + ( + corners[a], + corners[b], + corners[c], + surface_id, + material_id, + ) + for a, b, c in faces + ] + + +def _export_hinged_doors( + visual_objects: list[bpy.types.Object], + material_name_ids: dict[str, int], + material_variant_ids: dict[ + tuple[str, tuple[float, float, float]], int + ], +) -> list[ExportHingedDoor]: + depsgraph = bpy.context.evaluated_depsgraph_get() + result: list[ExportHingedDoor] = [] + for source_object in _door_objects(visual_objects): + mesh, evaluated = _mesh_for_export( + source_object, + depsgraph, + preserve_all_data_layers=True, + ) + try: + mesh.calc_loop_triangles() + uv0 = mesh.uv_layers.get("UVMap") + uv1 = mesh.uv_layers.get("Lightmap") + if uv0 is None or uv1 is None: + raise ValueError( + f"hinged door {source_object.name!r} requires UVMap " + "and Lightmap UV layers" + ) + if not mesh.loop_triangles: + raise ValueError( + f"hinged door {source_object.name!r} has no triangles" + ) + + hinge_source = source_object.get("ow_hinge_position") + hinge = ( + Vector(tuple(float(value) for value in hinge_source)) + if hinge_source is not None + else source_object.matrix_world.translation.copy() + ) + axis = _normalized_vector( + source_object.get("ow_hinge_axis", (0.0, 0.0, 1.0)), + f"hinged door {source_object.name!r} axis", + ) + world_corners = [ + source_object.matrix_world @ Vector(corner) + for corner in source_object.bound_box + ] + center = sum(world_corners, Vector()) / len(world_corners) + width = center - hinge + width -= axis * width.dot(axis) + if width.length <= 0.05: + raise ValueError( + f"hinged door {source_object.name!r} origin/hinge must " + "lie on one side of the door leaf" + ) + width.normalize() + depth = width.cross(axis).normalized() + # Re-orthogonalize width after the cross product so slightly + # imprecise authored axes cannot introduce render/collision drift. + width = axis.cross(depth).normalized() + + def local_point(point: Vector) -> Vector: + delta = point - hinge + return Vector( + (delta.dot(width), delta.dot(axis), delta.dot(depth)) + ) + + def local_direction(direction: Vector) -> Vector: + return Vector( + ( + direction.dot(width), + direction.dot(axis), + direction.dot(depth), + ) + ) + + normal_matrix = ( + source_object.matrix_world.to_3x3().inverted().transposed() + ) + vertices: list[tuple] = [] + indices: list[int] = [] + local_points: list[Vector] = [] + for triangle in mesh.loop_triangles: + polygon = mesh.polygons[triangle.polygon_index] + if polygon.material_index >= len(mesh.materials): + raise ValueError( + f"hinged door {source_object.name!r} has an invalid " + "material slot" + ) + material = mesh.materials[polygon.material_index] + if material is None or material.name not in material_name_ids: + raise ValueError( + f"hinged door {source_object.name!r} has an " + "unexported material" + ) + variant_key = ( + material.name, + _object_material_tint(source_object, material), + ) + material_id = material_variant_ids.get( + variant_key, material_name_ids[material.name] + ) + for loop_index in triangle.loops: + loop = mesh.loops[loop_index] + world_point = ( + source_object.matrix_world + @ mesh.vertices[loop.vertex_index].co + ) + point = local_point(world_point) + world_normal = ( + normal_matrix @ loop.normal + ).normalized() + normal = local_direction(world_normal).normalized() + base_uv = uv0.data[loop_index].uv + light_uv = uv1.data[loop_index].uv + local_points.append(point) + vertices.append( + ( + tuple(float(value) for value in point), + tuple(float(value) for value in normal), + (float(base_uv.x), float(base_uv.y)), + (float(light_uv.x), float(light_uv.y)), + material_id, + ) + ) + indices.append(len(vertices) - 1) + + minimum = Vector( + tuple(min(point[axis_index] for point in local_points) + for axis_index in range(3)) + ) + maximum = Vector( + tuple(max(point[axis_index] for point in local_points) + for axis_index in range(3)) + ) + # Very thin visual planes still need a stable physical leaf. + if maximum.z - minimum.z < 0.08: + middle = (minimum.z + maximum.z) * 0.5 + minimum.z = middle - 0.04 + maximum.z = middle + 0.04 + + material_name = str( + source_object.get("ow_door_collision_material", "") + ) + if not material_name and len(mesh.materials): + material = mesh.materials[0] + material_name = material.name if material else "" + if material_name not in material_name_ids: + raise ValueError( + f"hinged door {source_object.name!r} collision material " + f"{material_name!r} is not exported" + ) + material_id = material_name_ids[material_name] + surface_id = len(result) + 1 + + minimum_angle = math.radians( + float(source_object.get( + "ow_door_min_angle_degrees", -105.0 + )) + ) + maximum_angle = math.radians( + float(source_object.get( + "ow_door_max_angle_degrees", 105.0 + )) + ) + initial_angle = math.radians( + float(source_object.get( + "ow_door_initial_angle_degrees", 0.0 + )) + ) + if not minimum_angle < maximum_angle: + raise ValueError( + f"hinged door {source_object.name!r} has invalid limits" + ) + if not minimum_angle <= initial_angle <= maximum_angle: + raise ValueError( + f"hinged door {source_object.name!r} initial angle lies " + "outside its limits" + ) + result.append( + ExportHingedDoor( + name=str(source_object.get( + "ow_door_name", source_object.name + )), + hinge_position=_to_runtime(hinge), + hinge_axis=_to_runtime(axis), + closed_width_axis=_to_runtime(width), + closed_depth_axis=_to_runtime(depth), + local_min=tuple(float(value) for value in minimum), + local_max=tuple(float(value) for value in maximum), + minimum_angle_radians=minimum_angle, + maximum_angle_radians=maximum_angle, + initial_angle_radians=initial_angle, + mass=float(source_object.get("ow_door_mass", 32.0)), + angular_damping=float(source_object.get( + "ow_door_angular_damping", 2.2 + )), + return_spring_strength=float(source_object.get( + "ow_door_return_spring_strength", 0.0 + )), + maximum_angular_speed=float(source_object.get( + "ow_door_maximum_angular_speed", 8.0 + )), + contact_impulse_scale=float(source_object.get( + "ow_door_contact_impulse_scale", 1.0 + )), + friction=float(source_object.get( + "ow_door_friction", 0.55 + )), + restitution=float(source_object.get( + "ow_door_restitution", 0.02 + )), + surface_id=surface_id, + vertices=vertices, + indices=indices, + collision=_door_box_collision( + minimum, maximum, surface_id, material_id + ), + ) + ) + finally: + if evaluated is not None: + evaluated.to_mesh_clear() + return result + + +def export_scene( + output_path: str | Path, + *, + force_rebuild: bool = False, + metadata_only: bool = False, + adopt_existing_cache: bool = False, + export_editable_objects: bool = True, + progress: ProgressCallback | None = None, +) -> Path: + started = time.perf_counter() + output = Path(output_path).resolve() + output.parent.mkdir(parents=True, exist_ok=True) + _report_progress(progress, 0.0, "Preparing export") + + if metadata_only: + manifest = _load_cache_manifest(output) + if manifest is None or not _manifest_matches_package(output, manifest): + raise ValueError( + "metadata-only export requires a valid incremental cache; " + "run a normal export once or use --adopt-existing-cache" + ) + _patch_package_metadata(output, manifest) + _refresh_manifest_file_state(output, manifest) + print( + "SKATE incremental export:", + output, + "mode=metadata_only", + f"seconds={time.perf_counter() - started:.3f}", + flush=True, + ) + _report_progress(progress, 1.0, "Metadata export complete") + return output + + visual_objects = [ + obj + for obj in _objects_from_collections( + PRESENTATION_COLLISION_COLLECTION, + NO_COLLISION_COLLECTION, + LEGACY_VISUAL_COLLECTION, + ) + if bool(obj.get("ow_export_visual", True)) + and not _is_helper_object(obj) + ] + static_visual_objects = _static_visual_objects(visual_objects) + collision_objects = [ + obj + for obj in _objects_from_collections( + PRESENTATION_COLLISION_COLLECTION, + NO_PRESENTATION_COLLECTION, + LEGACY_COLLISION_COLLECTION, + ) + if not _is_helper_object(obj) + ] + grind_objects = _objects_from_collections( + GRIND_COLLECTION, LEGACY_GRIND_COLLECTION + ) + npc_path_objects = _objects_from_collections( + NPC_PATH_COLLECTION, LEGACY_NPC_PATH_COLLECTION + ) + material_variants = _used_export_material_variants( + visual_objects, collision_objects + ) + materials: list[bpy.types.Material] = [] + seen_materials: set[int] = set() + for material, _tint in material_variants: + identity = material.as_pointer() + if identity not in seen_materials: + seen_materials.add(identity) + materials.append(material) + images, image_ids = _referenced_images(materials) + material_variant_ids = { + (material.name, tint): index + 1 + for index, (material, tint) in enumerate(material_variants) + } + material_name_ids: dict[str, int] = {} + for material, tint in material_variants: + material_name_ids.setdefault( + material.name, + material_variant_ids[(material.name, tint)], + ) + + export_materials: list[ExportMaterial] = [] + for material, tint in material_variants: + material_id = material_variant_ids[ + (material.name, tint) + ] + base_color = _material_color(material) + display_color = tuple( + base_color[index] * tint[index] for index in range(3) + ) + export_name = material.name + if tint != (1.0, 1.0, 1.0): + export_name += " [Object Color " + ",".join( + f"{value:.3f}" for value in tint + ) + "]" + ( + retail_shader_name, + retail_shader_family, + retail_render_flags, + retail_material_guid, + retail_material_handle, + retail_material_group_index, + retail_texture_bindings, + retail_parameters, + retail_source_metadata, + ) = _retail_material_data(material, image_ids) + export_materials.append( + ExportMaterial( + blender_material=material, + material_id=material_id, + export_name=export_name, + display_color=display_color, + albedo_texture=_texture_id( + material, "ow_albedo_image", image_ids + ), + lightmap_texture=_texture_id( + material, "ow_lightmap_image", image_ids + ), + normal_texture=_texture_id( + material, "ow_normal_image", image_ids + ), + orm_texture=_texture_id( + material, "ow_orm_image", image_ids + ), + emissive_texture=_texture_id( + material, "ow_emissive_image", image_ids + ), + secondary_albedo_texture=_texture_id( + material, "ow_secondary_albedo_image", image_ids + ), + blend_mask_texture=_texture_id( + material, "ow_blend_mask_image", image_ids + ), + retail_shader_name=retail_shader_name, + retail_shader_family=retail_shader_family, + retail_render_flags=retail_render_flags, + retail_material_guid=retail_material_guid, + retail_material_handle=retail_material_handle, + retail_material_group_index=retail_material_group_index, + retail_texture_bindings=retail_texture_bindings, + retail_parameters=retail_parameters, + retail_source_metadata=retail_source_metadata, + ) + ) + + # Collision is audited before cache checks so a previously cached package + # cannot conceal newly-invalid source geometry. Harmless zero-area and + # duplicate triangles are omitted from the package without modifying the + # Blender mesh (or its visual UVs). + _report_progress(progress, 0.05, "Auditing collision geometry") + collision, _collision_audit, collision_object_ranges = _export_collision( + collision_objects, + material_name_ids, + export_editable_objects, + progress=lambda fraction, stage: _report_progress( + progress, + 0.05 + fraction * 0.20, + stage, + ), + ) + _report_progress(progress, 0.25, "Collision geometry ready") + + fingerprint: SceneContentFingerprint | None = None + manifest = _load_cache_manifest(output) + if adopt_existing_cache: + if not output.is_file(): + raise ValueError( + "--adopt-existing-cache requires an existing SKATE package" + ) + fingerprint = _scene_content_fingerprint( + visual_objects, + collision_objects, + grind_objects, + npc_path_objects, + materials, + images, + len(collision), + export_editable_objects, + progress=lambda fraction, stage: _report_progress( + progress, + 0.25 + fraction * 0.05, + stage, + ), + ) + package_name, _, package_counts = _read_package_header(output) + expected_name, _ = _scene_metadata(output) + expected_counts_except_vertices = ( + len(export_materials), + len(images), + fingerprint.visual_indices, + fingerprint.collision_triangles, + fingerprint.grind_rails, + fingerprint.hinged_doors, + fingerprint.local_lights, + fingerprint.npc_routes, + ) + actual_counts_except_vertices = ( + package_counts[0], + package_counts[1], + *package_counts[3:], + ) + if ( + package_name != expected_name + or actual_counts_except_vertices + != expected_counts_except_vertices + or package_counts[2] == 0 + or package_counts[2] > fingerprint.visual_vertices + ): + raise ValueError( + "existing package counts do not match the Blender scene; " + "a full export is required" + ) + _patch_package_metadata(output) + _write_cache_manifest( + output, fingerprint, len(export_materials), len(images) + ) + print( + "SKATE incremental cache adopted:", + output, + f"seconds={time.perf_counter() - started:.3f}", + flush=True, + ) + _report_progress(progress, 1.0, "Cache adoption complete") + return output + + if not force_rebuild and manifest is not None: + fingerprint = _scene_content_fingerprint( + visual_objects, + collision_objects, + grind_objects, + npc_path_objects, + materials, + images, + len(collision), + export_editable_objects, + progress=lambda fraction, stage: _report_progress( + progress, + 0.25 + fraction * 0.05, + stage, + ), + ) + if ( + fingerprint.digest == manifest.get("content_sha256") + and _manifest_matches_package(output, manifest) + ): + _patch_package_metadata(output, manifest) + _refresh_manifest_file_state(output, manifest) + print( + "SKATE incremental export:", + output, + "mode=content_cache_hit", + f"seconds={time.perf_counter() - started:.3f}", + flush=True, + ) + _report_progress(progress, 1.0, "Fast export complete") + return output + print( + "SKATE cache: content changed; rebuilding package", + flush=True, + ) + + geometry = _export_visual_geometry( + static_visual_objects, + material_variant_ids, + progress=lambda fraction, stage: _report_progress( + progress, + 0.30 + fraction * 0.40, + stage, + ), + ) + _report_progress(progress, 0.71, "Packing map metadata") + rails = _export_grinds(grind_objects) + npc_routes = _export_npc_routes(npc_path_objects) + doors = _export_hinged_doors( + visual_objects, + material_name_ids, + material_variant_ids, + ) + lights = _export_local_lights() + + spawn = bpy.data.objects.get(SPAWN_OBJECT) + if spawn is None: + raise ValueError(f"required spawn object is missing: {SPAWN_OBJECT}") + spawn_position = _to_runtime(spawn.matrix_world.translation) + heading = _spawn_heading(spawn) + scene = bpy.context.scene + sun_color, sun_intensity, orbit_azimuth = _sun_metadata() + + world_config_value_offset: int | None = None + with output.open("wb") as stream: + stream.write(MAGIC) + _write_u32(stream, ENDIAN_MARKER) + _write_string(stream, str(scene.get("ow_map_name", output.stem))) + _write_vec(stream, spawn_position) + _write_f32(stream, heading) + _write_vec(stream, scene.get("ow_sky_zenith", (0.09, 0.34, 0.72))) + _write_vec(stream, scene.get("ow_sky_horizon", (0.58, 0.78, 0.98))) + _write_vec(stream, scene.get("ow_sky_nadir", (0.18, 0.25, 0.34))) + _write_f32(stream, float(scene.get("ow_cycle_seconds", 96.0))) + _write_f32(stream, float(scene.get("ow_start_hour", 9.0))) + _write_f32(stream, orbit_azimuth) + _write_f32(stream, float(scene.get("ow_end_hour", 17.0))) + _write_f32( + stream, + 1.0 if bool(scene.get("ow_cycle_ping_pong", False)) else 0.0, + ) + _write_vec( + stream, + scene.get("ow_twilight_zenith", (0.045, 0.10, 0.26)), + ) + _write_vec( + stream, + scene.get("ow_twilight_horizon", (1.0, 0.32, 0.10)), + ) + _write_vec( + stream, + scene.get("ow_twilight_nadir", (0.05, 0.035, 0.06)), + ) + _write_vec( + stream, + scene.get("ow_night_zenith", (0.007, 0.015, 0.045)), + ) + _write_vec( + stream, + scene.get("ow_night_horizon", (0.045, 0.085, 0.17)), + ) + _write_vec( + stream, + scene.get("ow_night_nadir", (0.008, 0.014, 0.032)), + ) + _write_vec(stream, sun_color) + _write_vec( + stream, scene.get("ow_moon_color", (0.42, 0.56, 0.92)) + ) + _write_f32(stream, sun_intensity) + _write_f32(stream, float(scene.get("ow_moon_intensity", 0.18))) + _write_f32(stream, float(scene.get("ow_day_ambient", 0.32))) + _write_f32(stream, float(scene.get("ow_night_ambient", 0.11))) + _write_vec(stream, scene.get("ow_sky_tint", (1.0, 1.0, 1.0))) + for count in ( + len(export_materials), + len(images), + geometry.vertex_count, + geometry.index_count, + len(collision), + len(rails), + len(doors), + len(lights), + len(npc_routes), + ): + _write_u32(stream, count) + + package_stream = stream + material_stream = io.BytesIO() + stream = material_stream + for exported in export_materials: + material = exported.blender_material + lightmap_encoding = str( + material.get("ow_lightmap_encoding", "") + ) + baked_strength = float( + material.get("ow_baked_strength", 1.0) + ) + if ( + exported.lightmap_texture + and lightmap_encoding + == "skate3_retail_sqrt_linear_over_4" + ): + # The owned shader decodes authored Blender bakes from + # sqrt(linear / 4) with encoded^2 * 4. Retail pages already + # contain the console light value consumed as encoded^2, + # so compensate the common decode without altering a byte + # of the source page or the artist-facing strength control. + baked_strength *= 0.25 + _write_string(stream, exported.export_name) + _write_u32(stream, int(material.get("ow_flags", 1))) + _write_f32(stream, float(material.get("ow_friction", 0.82))) + _write_f32(stream, float(material.get("ow_restitution", 0.0))) + _write_vec(stream, exported.display_color) + _write_f32(stream, float(material.get("ow_roughness", 0.78))) + _write_f32(stream, float(material.get("ow_emissive", 0.0))) + _write_u32(stream, exported.albedo_texture) + _write_u32(stream, exported.lightmap_texture) + _write_f32(stream, baked_strength) + _write_u32(stream, exported.normal_texture) + _write_u32(stream, exported.orm_texture) + _write_u32(stream, exported.emissive_texture) + _write_u32( + stream, + _effective_alpha_mode(material), + ) + alpha_cutoff = float(material.get("ow_alpha_cutoff", 0.5)) + if not 0.0 <= alpha_cutoff <= 1.0: + raise ValueError( + f"material {material.name!r} has invalid alpha cutoff" + ) + _write_f32(stream, alpha_cutoff) + _write_u32( + stream, + _bounded_int(material, "ow_audio_surface", 3, 93), + ) + _write_u32( + stream, + _bounded_int(material, "ow_physics_surface", 1, 13), + ) + _write_u32( + stream, + _bounded_int(material, "ow_surface_pattern", 0, 15), + ) + _write_u32(stream, _presentation_depth_layer(material)) + retail_enabled = bool(exported.retail_shader_name) + _write_u32(stream, 1 if retail_enabled else 0) + if retail_enabled: + _write_u64(stream, exported.retail_material_guid) + _write_u32(stream, exported.retail_material_handle) + stream.write( + struct.pack( + " Path: + if arguments is None: + arguments = ( + sys.argv[sys.argv.index("--") + 1 :] + if "--" in sys.argv + else [] + ) + if not arguments: + raise SystemExit("usage: blender --background file.blend --python " + "export_skate.py -- output.skate " + "[--force|--metadata-only|--adopt-existing-cache] " + "[--no-editable-objects]") + flags = set(arguments[1:]) + allowed_flags = { + "--force", + "--metadata-only", + "--adopt-existing-cache", + "--no-editable-objects", + } + unknown_flags = flags - allowed_flags + if unknown_flags: + raise SystemExit( + "unknown SKATE export arguments: " + + ", ".join(sorted(unknown_flags)) + ) + selected_modes = sum( + flag in flags + for flag in ("--force", "--metadata-only", "--adopt-existing-cache") + ) + if selected_modes > 1: + raise SystemExit( + "--force, --metadata-only, and --adopt-existing-cache are " + "mutually exclusive" + ) + return export_scene( + arguments[0], + force_rebuild="--force" in flags, + metadata_only="--metadata-only" in flags, + adopt_existing_cache="--adopt-existing-cache" in flags, + export_editable_objects="--no-editable-objects" not in flags, + ) + + +if __name__ == "__main__": + main() diff --git a/tools/vendor/university/tools/blender_owned_map/prepare_university_editable.py b/tools/vendor/university/tools/blender_owned_map/prepare_university_editable.py new file mode 100644 index 00000000..9666246c --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/prepare_university_editable.py @@ -0,0 +1,890 @@ +"""Prepare an editable University map from the extracted scene. + +The University retail extraction contains material-batched presentation +meshes and separately extracted exact collision. A retail mesh part is not +automatically an authored object: most ``sur_`` records are floor, wall, or +material fragments. The curated profile exposes compact retail props and +named skate features. The aggressive profile intentionally exposes every +visual mesh record, up to a deterministic 20,000-object ceiling, for editor +performance stress testing. Both profiles report every classification +decision before export. + +Usage: + blender --background DIST_University_Owned.blend \ + --python prepare_university_editable.py -- --audit-only +""" + +from __future__ import annotations + +import argparse +from collections import Counter +from dataclasses import dataclass +import json +import math +from pathlib import Path +import sys + +import bpy +from mathutils import Matrix, Vector +from mathutils.bvhtree import BVHTree + + +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon # noqa: E402 +from owned_world_material_addon import exporter # noqa: E402 + +COLLISION_TOOL_ROOT = ( + TOOL_ROOT.parent + / "vanilla_map_extraction" + / "tools" +) +if str(COLLISION_TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(COLLISION_TOOL_ROOT)) +from attach_retail_collision_identity import attach_identity # noqa: E402 + + +# These are intentionally semantic world markers, not generic material words +# such as "concrete" or "metal" that also appear on movable skate props. +PERMANENT_WORLD_MARKERS = ( + "architecture", + "background", + "building", + "bush", + "ceiling", + "cityblock", + "cliff", + "cloud", + "decal", + "facade", + "foliage", + "foundation", + "ground", + "grass", + "landscape", + "leaf", + "mountain", + "ocean", + "pavement", + "reflection", + "road", + "roof", + "rock", + "shadow", + "sidewalk", + "sky", + "street", + "terrain", + "tree", + "tunnel", + "water", + "wall", + "window", +) + +# Surface records are static by default. Only names that describe a coherent +# skate obstacle or practical prop become independently editable. +SKATE_FEATURE_SURFACE_MARKERS = ( + "bank", + "barrier", + "bench", + "block", + "bowl", + "coping", + "curb", + "fence", + "hubba", + "kicker", + "ledge", + "manualpad", + "pipe", + "planter", + "quarter", + "rail", + "ramp", + "skate", + "stair", + "vent", +) + +STANDALONE_PROP_PREFIXES = ( + "fire_hydrant", + "sportscar", + "traffic_", + "traf4sedan", + "trafsuva", + "tmsag", + "woodenpalette", +) + +MAX_HORIZONTAL_SPAN = 40.0 +MAX_VERTICAL_SPAN = 25.0 +MAX_FOOTPRINT_AREA = 650.0 +MAX_TRIANGLES = 20_000 +AGGRESSIVE_EDITABLE_LIMIT = 20_000 +COLLISION_GRID_SIZE = 20.0 +COLLISION_MATCH_DISTANCE = 0.32 +COLLISION_BOUNDS_MARGIN = 0.45 +MAX_COLLISION_COMPONENT_SPAN = 45.0 +MAX_COLLISION_COMPONENT_TRIANGLES = 20_000 + + +@dataclass(frozen=True) +class Classification: + object_name: str + editable: bool + reason: str + dimensions: tuple[float, float, float] + triangles: int + bounds_min: tuple[float, float, float] + bounds_max: tuple[float, float, float] + + +def parse_arguments() -> argparse.Namespace: + arguments = ( + sys.argv[sys.argv.index("--") + 1 :] + if "--" in sys.argv + else [] + ) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--audit-only", action="store_true") + parser.add_argument( + "--selection-profile", + choices=("curated", "aggressive"), + default="aggressive", + help=( + "curated selects coherent props; aggressive selects every visual " + "mesh record up to the 20,000-object stress-test ceiling" + ), + ) + parser.add_argument("--report", type=Path) + parser.add_argument("--output-blend", type=Path) + parser.add_argument("--output-skate", type=Path) + parser.add_argument("--retail-collision-archive", type=Path) + return parser.parse_args(arguments) + + +def world_bounds( + obj: bpy.types.Object, +) -> tuple[Vector, Vector]: + corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + minimum = Vector( + tuple(min(corner[axis] for corner in corners) for axis in range(3)) + ) + maximum = Vector( + tuple(max(corner[axis] for corner in corners) for axis in range(3)) + ) + return minimum, maximum + + +def semantic_identity(obj: bpy.types.Object) -> str: + values = [ + obj.name, + str(obj.get("skate3_material_name", "")), + str(obj.get("skate3_shader_name", "")), + ] + raw_parameters = str(obj.get("skate3_retail_parameters", "")) + if raw_parameters: + try: + parameters = json.loads(raw_parameters) + values.extend(str(value) for value in parameters.get("Name", [])) + except (TypeError, ValueError): + values.append(raw_parameters) + return " ".join(values).lower() + + +def proper_object_kind(obj: bpy.types.Object) -> str | None: + name = obj.name_full.lower() + if name.startswith("obj_"): + return "retail_object" + if name.startswith("sur_"): + if any(marker in name for marker in SKATE_FEATURE_SURFACE_MARKERS): + return "skate_feature_surface" + return None + if name.startswith(STANDALONE_PROP_PREFIXES): + return "standalone_prop" + return None + + +def classify( + obj: bpy.types.Object, + selection_profile: str, +) -> Classification: + minimum, maximum = world_bounds(obj) + dimensions_vector = maximum - minimum + dimensions = tuple(float(value) for value in dimensions_vector) + obj.data.calc_loop_triangles() + triangles = len(obj.data.loop_triangles) + + if selection_profile == "aggressive": + editable = True + reason = "aggressive_visual_record" + else: + object_kind = proper_object_kind(obj) + reason = ( + f"proper_{object_kind}" if object_kind is not None else "" + ) + editable = True + identity = semantic_identity(obj) + marker = next( + ( + candidate + for candidate in PERMANENT_WORLD_MARKERS + if candidate in identity + ), + None, + ) + if object_kind is None: + editable = False + reason = "not_proper_object" + elif marker is not None: + editable = False + reason = f"permanent_marker:{marker}" + elif max(dimensions[0], dimensions[1]) > MAX_HORIZONTAL_SPAN: + editable = False + reason = "large_horizontal_span" + elif dimensions[2] > MAX_VERTICAL_SPAN: + editable = False + reason = "large_vertical_span" + elif dimensions[0] * dimensions[1] > MAX_FOOTPRINT_AREA: + editable = False + reason = "large_footprint" + elif triangles > MAX_TRIANGLES: + editable = False + reason = "large_triangle_count" + + return Classification( + object_name=obj.name_full, + editable=editable, + reason=reason, + dimensions=dimensions, + triangles=triangles, + bounds_min=tuple(float(value) for value in minimum), + bounds_max=tuple(float(value) for value in maximum), + ) + + +def audit_scene( + selection_profile: str, +) -> tuple[list[bpy.types.Object], list[Classification]]: + visual_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_COLLISION_COLLECTION, + exporter.LEGACY_VISUAL_COLLECTION, + ) + if obj.type == "MESH" + and bool(obj.get("ow_export_visual", True)) + and not exporter._is_helper_object(obj) + ] + classifications = [ + classify(obj, selection_profile) for obj in visual_objects + ] + if ( + selection_profile == "aggressive" + and len(classifications) > AGGRESSIVE_EDITABLE_LIMIT + ): + retained_names = { + item.object_name + for item in sorted( + classifications, + key=lambda item: item.object_name.casefold(), + )[:AGGRESSIVE_EDITABLE_LIMIT] + } + classifications = [ + ( + item + if item.object_name in retained_names + else Classification( + object_name=item.object_name, + editable=False, + reason="aggressive_object_limit", + dimensions=item.dimensions, + triangles=item.triangles, + bounds_min=item.bounds_min, + bounds_max=item.bounds_max, + ) + ) + for item in classifications + ] + return visual_objects, classifications + + +def report_payload( + classifications: list[Classification], + selection_profile: str, +) -> dict[str, object]: + editable = [item for item in classifications if item.editable] + static = [item for item in classifications if not item.editable] + return { + "format": "university-editable-classification-v2", + "source_blend": str(Path(bpy.data.filepath).resolve()), + "selection_profile": selection_profile, + "aggressive_editable_limit": AGGRESSIVE_EDITABLE_LIMIT, + "thresholds": { + "maximum_horizontal_span": MAX_HORIZONTAL_SPAN, + "maximum_vertical_span": MAX_VERTICAL_SPAN, + "maximum_footprint_area": MAX_FOOTPRINT_AREA, + "maximum_triangles": MAX_TRIANGLES, + }, + "counts": { + "visual_objects": len(classifications), + "editable_objects": len(editable), + "static_objects": len(static), + "editable_triangles": sum(item.triangles for item in editable), + "static_triangles": sum(item.triangles for item in static), + }, + "reasons": dict( + sorted(Counter(item.reason for item in classifications).items()) + ), + "largest_editable": [ + { + "name": item.object_name, + "dimensions": item.dimensions, + "triangles": item.triangles, + } + for item in sorted( + editable, + key=lambda item: ( + math.prod(item.dimensions), + item.triangles, + ), + reverse=True, + )[:50] + ], + "objects": [ + { + "name": item.object_name, + "editable": item.editable, + "reason": item.reason, + "dimensions": item.dimensions, + "triangles": item.triangles, + "bounds_min": item.bounds_min, + "bounds_max": item.bounds_max, + } + for item in classifications + ], + } + + +def grid_cells( + minimum: Vector, + maximum: Vector, + cell_size: float = COLLISION_GRID_SIZE, +): + starts = [ + math.floor(minimum[axis] / cell_size) for axis in range(3) + ] + ends = [ + math.floor(maximum[axis] / cell_size) for axis in range(3) + ] + for x in range(starts[0], ends[0] + 1): + for y in range(starts[1], ends[1] + 1): + for z in range(starts[2], ends[2] + 1): + yield (x, y, z) + + +def bounds_overlap( + first_minimum: Vector, + first_maximum: Vector, + second_minimum: Vector, + second_maximum: Vector, + margin: float = 0.0, +) -> bool: + return all( + first_maximum[axis] + margin >= second_minimum[axis] + and second_maximum[axis] + margin >= first_minimum[axis] + for axis in range(3) + ) + + +def object_bvh(obj: bpy.types.Object) -> BVHTree: + obj.data.calc_loop_triangles() + vertices = [ + obj.matrix_world @ vertex.co for vertex in obj.data.vertices + ] + polygons = [ + tuple(int(index) for index in triangle.vertices) + for triangle in obj.data.loop_triangles + ] + return BVHTree.FromPolygons(vertices, polygons, all_triangles=True) + + +def stable_object_id(name: str) -> int: + return int(exporter._stable_object_id(name)) + + +def signed_i32(value: int) -> int: + return value if value < 0x80000000 else value - 0x100000000 + + +def collision_components( + mesh: bpy.types.Mesh, +) -> list[list[int]]: + polygon_count = len(mesh.polygons) + parent = list(range(polygon_count)) + rank = bytearray(polygon_count) + + def find(index: int) -> int: + while parent[index] != index: + parent[index] = parent[parent[index]] + index = parent[index] + return index + + def union(first: int, second: int) -> None: + first_root = find(first) + second_root = find(second) + if first_root == second_root: + return + if rank[first_root] < rank[second_root]: + first_root, second_root = second_root, first_root + parent[second_root] = first_root + if rank[first_root] == rank[second_root]: + rank[first_root] += 1 + + vertex_owner: dict[int, int] = {} + for polygon in mesh.polygons: + polygon_index = int(polygon.index) + for vertex_index in polygon.vertices: + previous = vertex_owner.setdefault( + int(vertex_index), polygon_index + ) + union(polygon_index, previous) + + grouped: dict[int, list[int]] = {} + for polygon_index in range(polygon_count): + grouped.setdefault(find(polygon_index), []).append(polygon_index) + return list(grouped.values()) + + +def assign_collision_ownership( + editable_objects: list[bpy.types.Object], +) -> dict[str, object]: + object_bounds = { + obj: world_bounds(obj) for obj in editable_objects + } + grid: dict[tuple[int, int, int], list[bpy.types.Object]] = {} + for obj, (minimum, maximum) in object_bounds.items(): + for cell in grid_cells(minimum, maximum): + grid.setdefault(cell, []).append(obj) + + cached_bvhs: dict[bpy.types.Object, BVHTree] = {} + owned_triangles: Counter[str] = Counter() + owned_components: Counter[str] = Counter() + collision_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.PRESENTATION_COLLISION_COLLECTION, + exporter.NO_PRESENTATION_COLLECTION, + exporter.LEGACY_COLLISION_COLLECTION, + ) + if obj.type == "MESH" + and not exporter._is_helper_object(obj) + ] + source_components = 0 + source_triangles = 0 + matched_components = 0 + matched_triangles = 0 + + for object_index, collision_object in enumerate( + collision_objects, start=1 + ): + material = bpy.data.materials.get( + str(collision_object.get("ow_material", "")) + ) + if material is not None and not bool( + material.get("ow_collision_enabled", True) + ): + continue + mesh = collision_object.data + mesh.calc_loop_triangles() + if any(len(polygon.vertices) != 3 for polygon in mesh.polygons): + raise RuntimeError( + f"{collision_object.name!r} contains non-triangle " + "retail collision" + ) + existing = mesh.attributes.get( + exporter.EDITOR_COLLISION_OWNER_ATTRIBUTE + ) + if existing is not None: + mesh.attributes.remove(existing) + owner_attribute = mesh.attributes.new( + exporter.EDITOR_COLLISION_OWNER_ATTRIBUTE, + "INT", + "FACE", + ) + owner_values = [0] * len(mesh.polygons) + world_vertices = [ + collision_object.matrix_world @ vertex.co + for vertex in mesh.vertices + ] + components = collision_components(mesh) + source_components += len(components) + source_triangles += len(mesh.polygons) + + for component in components: + minimum = Vector((math.inf, math.inf, math.inf)) + maximum = Vector((-math.inf, -math.inf, -math.inf)) + for polygon_index in component: + for vertex_index in mesh.polygons[polygon_index].vertices: + point = world_vertices[int(vertex_index)] + for axis in range(3): + minimum[axis] = min(minimum[axis], point[axis]) + maximum[axis] = max(maximum[axis], point[axis]) + dimensions = maximum - minimum + if ( + len(component) > MAX_COLLISION_COMPONENT_TRIANGLES + or max(dimensions.x, dimensions.y) + > MAX_COLLISION_COMPONENT_SPAN + or dimensions.z > MAX_COLLISION_COMPONENT_SPAN + ): + continue + + candidates: set[bpy.types.Object] = set() + for cell in grid_cells(minimum, maximum): + candidates.update(grid.get(cell, ())) + candidates = { + candidate + for candidate in candidates + if bounds_overlap( + minimum, + maximum, + *object_bounds[candidate], + COLLISION_BOUNDS_MARGIN, + ) + } + if not candidates: + continue + + sample_count = min(24, len(component)) + sample_indices = [ + component[ + min( + len(component) - 1, + sample * len(component) // sample_count, + ) + ] + for sample in range(sample_count) + ] + samples = [] + for polygon_index in sample_indices: + polygon = mesh.polygons[polygon_index] + points = [ + world_vertices[int(index)] + for index in polygon.vertices + ] + center = sum(points, Vector()) / 3.0 + normal = (points[1] - points[0]).cross( + points[2] - points[0] + ).normalized() + samples.append((center, normal)) + + best_object = None + best_score = None + for candidate in candidates: + bvh = cached_bvhs.get(candidate) + if bvh is None: + bvh = object_bvh(candidate) + cached_bvhs[candidate] = bvh + matches = 0 + distance_sum = 0.0 + for center, collision_normal in samples: + nearest = bvh.find_nearest( + center, COLLISION_MATCH_DISTANCE + ) + if nearest is None or nearest[0] is None: + continue + _location, visual_normal, _face, distance = nearest + if ( + distance <= COLLISION_MATCH_DISTANCE + and abs(visual_normal.dot(collision_normal)) >= 0.35 + ): + matches += 1 + distance_sum += float(distance) + minimum_matches = 1 if sample_count <= 3 else 2 + if matches < minimum_matches: + continue + score = ( + -matches / sample_count, + distance_sum / matches, + math.prod( + value + 0.01 + for value in ( + object_bounds[candidate][1] + - object_bounds[candidate][0] + ) + ), + candidate.name_full, + ) + if best_score is None or score < best_score: + best_score = score + best_object = candidate + + if best_object is None: + continue + encoded_owner = signed_i32( + stable_object_id(best_object.name_full) + ) + for polygon_index in component: + owner_values[polygon_index] = encoded_owner + matched_components += 1 + matched_triangles += len(component) + owned_components[best_object.name_full] += 1 + owned_triangles[best_object.name_full] += len(component) + + owner_attribute.data.foreach_set("value", owner_values) + print( + "UNIVERSITY_COLLISION_MATCH", + f"object={object_index}/{len(collision_objects)}", + f"name={collision_object.name}", + f"triangles={len(mesh.polygons)}", + f"components={len(components)}", + flush=True, + ) + + return { + "source_collision_objects": len(collision_objects), + "source_components": source_components, + "source_triangles": source_triangles, + "matched_components": matched_components, + "matched_triangles": matched_triangles, + "owners": len(owned_triangles), + "owner_triangles": dict(owned_triangles), + "owner_components": dict(owned_components), + } + + +def recenter_object(obj: bpy.types.Object) -> None: + minimum, maximum = world_bounds(obj) + pivot = (minimum + maximum) * 0.5 + old_matrix = obj.matrix_world.copy() + new_matrix = old_matrix.copy() + new_matrix.translation = pivot + transform = new_matrix.inverted() @ old_matrix + if obj.data.users > 1: + obj.data = obj.data.copy() + obj.data.transform(transform) + obj.matrix_world = new_matrix + obj.data.update() + + +def associate_grinds( + editable_objects: list[bpy.types.Object], +) -> dict[str, int]: + object_bounds = { + obj: world_bounds(obj) for obj in editable_objects + } + grid: dict[tuple[int, int, int], list[bpy.types.Object]] = {} + for obj, (minimum, maximum) in object_bounds.items(): + for cell in grid_cells(minimum, maximum): + grid.setdefault(cell, []).append(obj) + grind_objects = [ + obj + for obj in exporter._objects_from_collections( + exporter.GRIND_COLLECTION, + exporter.LEGACY_GRIND_COLLECTION, + ) + if obj.type == "CURVE" + ] + associated = 0 + for grind in grind_objects: + minimum, maximum = world_bounds(grind) + margin = 0.85 + expanded_minimum = minimum - Vector((margin, margin, margin)) + expanded_maximum = maximum + Vector((margin, margin, margin)) + candidates: set[bpy.types.Object] = set() + for cell in grid_cells(expanded_minimum, expanded_maximum): + candidates.update(grid.get(cell, ())) + candidates = { + candidate + for candidate in candidates + if bounds_overlap( + minimum, + maximum, + *object_bounds[candidate], + margin, + ) + } + if not candidates: + continue + best_owner = None + best_score = None + for candidate in candidates: + owner_minimum, owner_maximum = object_bounds[candidate] + if any( + minimum[axis] < owner_minimum[axis] - margin + or maximum[axis] > owner_maximum[axis] + margin + for axis in range(3) + ): + continue + dimensions = owner_maximum - owner_minimum + volume = max( + float(dimensions.x * dimensions.y * dimensions.z), + 0.000001, + ) + slack = sum( + max(float(minimum[axis] - owner_minimum[axis]), 0.0) + + max(float(owner_maximum[axis] - maximum[axis]), 0.0) + for axis in range(3) + ) + identity = semantic_identity(candidate) + semantic_rank = ( + 0 + if any( + marker in identity + for marker in ( + "rail", + "ledge", + "coping", + "bench", + "fence", + "bar", + ) + ) + else 1 + ) + score = ( + semantic_rank, + volume, + slack, + candidate.name_full, + ) + if best_score is None or score < best_score: + best_score = score + best_owner = candidate + if best_owner is None: + continue + owner = best_owner + world_matrix = grind.matrix_world.copy() + grind.parent = owner + grind.matrix_world = world_matrix + associated += 1 + return { + "source_grinds": len(grind_objects), + "associated_grinds": associated, + } + + +def main() -> int: + arguments = parse_arguments() + visual_objects, classifications = audit_scene( + arguments.selection_profile + ) + payload = report_payload( + classifications, + arguments.selection_profile, + ) + encoded = json.dumps(payload, indent=2) + print( + "UNIVERSITY_EDITABLE_AUDIT", + json.dumps(payload["counts"], sort_keys=True), + json.dumps(payload["reasons"], sort_keys=True), + flush=True, + ) + if arguments.report is not None: + report = arguments.report.resolve() + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(encoded + "\n", encoding="utf-8") + print(f"UNIVERSITY_EDITABLE_REPORT {report}", flush=True) + + if arguments.audit_only: + return 0 + if arguments.output_blend is None or arguments.output_skate is None: + raise RuntimeError( + "--output-blend and --output-skate are required for conversion" + ) + if arguments.retail_collision_archive is None: + raise RuntimeError( + "--retail-collision-archive is required for editable University " + "collision provenance" + ) + + by_name = {item.object_name: item for item in classifications} + for obj in visual_objects: + obj["ow_editor_editable"] = by_name[obj.name_full].editable + + provisional = [ + obj + for obj in visual_objects + if bool(obj.get("ow_editor_editable", False)) + ] + collision = assign_collision_ownership(provisional) + owners = set(collision["owner_triangles"]) + editable_objects = provisional + for obj in editable_objects: + recenter_object(obj) + # Blender does not immediately refresh every curve/object world bound after + # thousands of mesh datablocks are transformed in a background session. + # Grind ownership depends on those evaluated bounds, so force one scene + # update before matching them. + bpy.context.view_layer.update() + grinds = associate_grinds(editable_objects) + payload["collision_ownership"] = collision + payload["grind_ownership"] = grinds + payload["counts"]["collision_owned_editable_objects"] = len( + owners + ) + payload["counts"]["visual_only_editable_objects"] = ( + len(editable_objects) - len(owners) + ) + if arguments.report is not None: + arguments.report.resolve().write_text( + json.dumps(payload, indent=2) + "\n", + encoding="utf-8", + ) + print( + "UNIVERSITY_EDITABLE_OWNERSHIP", + json.dumps( + { + "editable_objects": len(editable_objects), + "visual_only_objects": ( + len(editable_objects) - len(owners) + ), + "collision_components": collision["matched_components"], + "collision_triangles": collision["matched_triangles"], + "associated_grinds": grinds["associated_grinds"], + }, + sort_keys=True, + ), + flush=True, + ) + + addon.register() + try: + output_blend = arguments.output_blend.resolve() + output_skate = arguments.output_skate.resolve() + output_blend.parent.mkdir(parents=True, exist_ok=True) + output_skate.parent.mkdir(parents=True, exist_ok=True) + bpy.context.scene["ow_map_name"] = "University District - Editable" + bpy.ops.wm.save_as_mainfile(filepath=str(output_blend)) + exporter.export_scene(output_skate, force_rebuild=True) + identity_output = output_skate.with_name( + output_skate.stem + ".rcid" + output_skate.suffix + ) + identity = attach_identity( + output_skate, + arguments.retail_collision_archive.resolve(), + identity_output, + ) + identity_output.replace(output_skate) + print( + "UNIVERSITY_EDITABLE_COLLISION_IDENTITY", + json.dumps(identity, sort_keys=True), + flush=True, + ) + finally: + addon.unregister() + print( + "UNIVERSITY_EDITABLE_EXPORT", + output_blend, + output_skate, + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/blender_owned_map/repack_skate_v15.py b/tools/vendor/university/tools/blender_owned_map/repack_skate_v15.py new file mode 100644 index 00000000..6d6829eb --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/repack_skate_v15.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Losslessly repack an existing SKATE12-14 package as SKATE15. + +The decoded material, texture, geometry, collision, authored-feature, and +extension bytes are preserved exactly. Only reversible storage transforms and +the optional per-map dynamic-lighting startup preference are changed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path +import struct +import sys +import zlib + +import numpy +try: + from compression import zstd +except ImportError: + zstd = None + +from analyze_skate import Reader, PackageError + + +MAGICS = {b"SKATE12\0": 12, b"SKATE13\0": 13, b"SKATE14\0": 14} +MAGIC_V15 = b"SKATE15\0" +STORAGE_RAW = 0 +STORAGE_DEFLATE = 1 +STORAGE_ZSTD = 2 +STORAGE_ZSTD_RGBA_FILTER = 3 +STORAGE_ZSTD_VERTEX_SOA = 4 +STORAGE_ZSTD_INDEX_DELTA = 5 +STORAGE_ZSTD_COLLISION_INDEXED = 6 +STORAGE_DEFLATE_RGBA_FILTER = 7 +STORAGE_DEFLATE_VERTEX_SOA = 8 +STORAGE_DEFLATE_INDEX_DELTA = 9 +STORAGE_DEFLATE_COLLISION_INDEXED = 10 +STORAGE_TEXTURE_REFERENCE = 11 +VERTEX_BYTES = 56 +COLLISION_BYTES = 48 + + +def write_u32(stream, value: int) -> None: + stream.write(struct.pack(" None: + write_u32(stream, method) + write_u32(stream, len(payload)) + stream.write(payload) + + +def best_plain_storage(decoded: bytes) -> tuple[int, bytes]: + candidates = [ + (STORAGE_DEFLATE, zlib.compress(decoded, level=9)), + ] + if zstd is not None: + candidates.append( + (STORAGE_ZSTD, zstd.compress(decoded, level=10)) + ) + method, payload = min(candidates, key=lambda item: len(item[1])) + return ( + (STORAGE_RAW, decoded) + if len(payload) >= len(decoded) + else (method, payload) + ) + + +def best_transformed_storage( + decoded: bytes, + transformed: bytes, + *, + zstd_method: int, + deflate_method: int, +) -> tuple[int, bytes]: + prefix = struct.pack("= len(decoded) + else selected + ) + + +def filtered_rgba8(decoded: bytes, width: int, height: int) -> bytes: + row_size = width * 4 + if len(decoded) != row_size * height: + raise PackageError("texture dimensions do not match RGBA8 bytes") + output = bytearray(struct.pack(" bytes: + if len(decoded) % VERTEX_BYTES: + raise PackageError("visual vertex block is not 56-byte aligned") + vertices = numpy.frombuffer(decoded, dtype=numpy.uint8).reshape( + -1, VERTEX_BYTES + ) + fields = ((0, 12), (12, 24), (24, 32), (32, 40), + (40, 44), (44, 52), (52, 56)) + return b"".join( + numpy.ascontiguousarray(vertices[:, first:last]).tobytes() + for first, last in fields + ) + + +def index_delta_varints(decoded: bytes) -> bytes: + if len(decoded) % 4: + raise PackageError("visual index block is not u32 aligned") + output = bytearray() + previous = 0 + for current in numpy.frombuffer(decoded, dtype="= 0 else ((-delta << 1) - 1) + while zigzag >= 0x80: + output.append((zigzag & 0x7F) | 0x80) + zigzag >>= 7 + output.append(zigzag) + previous = value + return bytes(output) + + +def indexed_collision(decoded: bytes) -> bytes: + if len(decoded) % COLLISION_BYTES: + raise PackageError("collision block is not 48-byte aligned") + records = numpy.frombuffer(decoded, dtype=numpy.uint8).reshape( + -1, COLLISION_BYTES + ) + positions = ( + records[:, :36] + .reshape(-1, 12) + .view(" bytes: + output = bytearray() + overlay_tokens = ( + "sign", "poster", "billboard", "adbord", "advert", + "banner", "logo", "decal", "graffiti", "sticker", + "plaque", "letter", "neon", + ) + for material in range(count): + name_start = reader.offset + name = reader.string(f"material {material} name") + output.extend(data[name_start : reader.offset]) + fields = reader.take(76, f"material {material} fields") + output.extend(fields) + if version >= 13: + output.extend( + reader.take(4, f"material {material} depth layer") + ) + else: + alpha_mode = struct.unpack_from(" None: + _, _, _, _, _, rails, doors, lights, routes = counts + for rail in range(rails): + reader.string(f"rail {rail} name") + reader.skip(4, f"rail {rail} closed") + representation = reader.u32(f"rail {rail} representation") + if representation == 0: + points = reader.u32(f"rail {rail} point count") + reader.skip(points * 12, f"rail {rail} points") + elif representation == 1: + reader.skip(24, f"rail {rail} retail header") + segments = reader.u32(f"rail {rail} segment count") + reader.skip(segments * 120, f"rail {rail} segments") + else: + raise PackageError(f"rail {rail} representation is invalid") + for door in range(doors): + reader.string(f"door {door} name") + reader.skip(116, f"door {door} header") + vertex_count = reader.u32(f"door {door} vertex count") + index_count = reader.u32(f"door {door} index count") + collision_count = reader.u32(f"door {door} collision count") + reader.skip(vertex_count * VERTEX_BYTES, f"door {door} vertices") + reader.skip(index_count * 4, f"door {door} indices") + reader.skip(collision_count * COLLISION_BYTES, f"door {door} collision") + for light in range(lights): + reader.string(f"light {light} name") + reader.skip(60, f"light {light} fields") + for route in range(routes): + reader.string(f"route {route} name") + reader.skip(16, f"route {route} fields") + points = reader.u32(f"route {route} point count") + reader.skip(points * 12, f"route {route} points") + + +def repack( + source: Path, + destination: Path, + *, + dynamic_lighting: bool, +) -> dict[str, int]: + data = source.read_bytes() + reader = Reader(data) + magic = reader.take(8, "magic") + version = MAGICS.get(magic) + if version is None: + raise PackageError("input must be an SKATE12, SKATE13, or SKATE14 map") + header_start = reader.offset + marker = reader.u32("endian marker") + if marker != 0x12345678: + raise PackageError("input endian marker is invalid") + reader.string("map name") + reader.skip(49 * 4, "map metadata") + counts = struct.unpack("<9I", reader.take(36, "count table")) + header_end = reader.offset + + material_bytes = material_block_v15( + data, reader, counts[0], version + ) + + temporary = destination.with_name(destination.name + ".tmp") + temporary.parent.mkdir(parents=True, exist_ok=True) + temporary.unlink(missing_ok=True) + stats: dict[str, int] = {} + with temporary.open("wb") as output: + output.write(MAGIC_V15) + output.write(data[header_start:header_end]) + write_u32(output, len(material_bytes)) + method, payload = best_plain_storage(material_bytes) + write_storage_record(output, method, payload) + stats["materials"] = len(payload) + 8 + + texture_payloads: dict[tuple[int, int, bytes], int] = {} + decoded_textures: list[bytes] = [] + texture_start = output.tell() + for texture in range(counts[1]): + record_start = reader.offset + reader.string(f"texture {texture} name") + width = reader.u32(f"texture {texture} width") + height = reader.u32(f"texture {texture} height") + reader.u32(f"texture {texture} color space") + metadata = data[record_start : reader.offset] + rgba8 = reader.stored( + width * height * 4, + f"texture {texture}", + decoded_textures, + ) + decoded_textures.append(rgba8) + output.write(metadata) + identity = (width, height, hashlib.sha256(rgba8).digest()) + reference = texture_payloads.get(identity) + if reference is not None: + write_storage_record( + output, + STORAGE_TEXTURE_REFERENCE, + struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + lighting = parser.add_mutually_exclusive_group() + lighting.add_argument( + "--dynamic-lighting-on", action="store_true", default=True + ) + lighting.add_argument( + "--dynamic-lighting-off", + action="store_false", + dest="dynamic_lighting_on", + ) + args = parser.parse_args() + stats = repack( + args.source.resolve(), + args.destination.resolve(), + dynamic_lighting=args.dynamic_lighting_on, + ) + saved = stats["input"] - stats["output"] + print( + f"SKATE15 repack: {args.destination.resolve()}\n" + f"input={stats['input']} output={stats['output']} " + f"saved={saved} ({saved / stats['input']:.2%})" + ) + for name in ( + "materials", "textures", "vertices", "indices", + "collision", "extensions", + ): + print(f"{name}={stats[name]}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, PackageError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/vendor/university/tools/blender_owned_map/select_collision_archive.py b/tools/vendor/university/tools/blender_owned_map/select_collision_archive.py new file mode 100644 index 00000000..0eca4c09 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/select_collision_archive.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Select the nearest native collision records from an RWCMSET1 archive.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import struct + + +MAGIC = b"RWCMSET1" + + +def _read_exact(stream, size: int) -> bytes: + value = stream.read(size) + if len(value) != size: + raise ValueError("collision archive is truncated") + return value + + +def _distance_squared( + x: float, + z: float, + minimum_x: float, + minimum_z: float, + maximum_x: float, + maximum_z: float, +) -> float: + dx = minimum_x - x if x < minimum_x else x - maximum_x if x > maximum_x else 0.0 + dz = minimum_z - z if z < minimum_z else z - maximum_z if z > maximum_z else 0.0 + return dx * dx + dz * dz + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path, nargs="?") + parser.add_argument("--count", type=int, default=32) + parser.add_argument("--center-x", type=float, default=0.0) + parser.add_argument("--center-z", type=float, default=0.0) + parser.add_argument( + "--describe", + action="store_true", + help="print record bounds and triangle counts without writing", + ) + args = parser.parse_args() + if args.count <= 0: + raise ValueError("selection count must be positive") + + archive_size = args.input.stat().st_size + records: list[dict[str, object]] = [] + with args.input.open("rb") as stream: + if _read_exact(stream, len(MAGIC)) != MAGIC: + raise ValueError("collision archive magic is invalid") + record_count = struct.unpack(" 4096: + raise ValueError(f"record {index} has an invalid name") + name = _read_exact(stream, name_size) + mesh_size = struct.unpack(" archive_size: + raise ValueError( + f"record {index} extends beyond the collision archive" + ) + header = _read_exact(stream, 48) + minimum_x, _, minimum_z = struct.unpack_from(">fff", header, 0) + maximum_x, _, maximum_z = struct.unpack_from(">fff", header, 16) + triangle_count = struct.unpack_from(">I", header, 40)[0] + records.append( + { + "index": index, + "name": name, + "mesh_offset": mesh_offset, + "mesh_size": mesh_size, + "triangles": triangle_count, + "distance_squared": _distance_squared( + args.center_x, + args.center_z, + minimum_x, + minimum_z, + maximum_x, + maximum_z, + ), + } + ) + stream.seek(mesh_offset + mesh_size) + if stream.read(1): + raise ValueError("collision archive has trailing bytes") + if not records: + raise ValueError("collision archive contains no records") + + selected = sorted( + records, + key=lambda record: ( + record["distance_squared"], + record["index"], + ), + )[: args.count] + if args.describe: + for record in sorted( + records, + key=lambda value: ( + -int(value["triangles"]), + int(value["index"]), + ), + ): + print( + f"{record['name'].decode('utf-8', errors='replace')}\t" + f"triangles={record['triangles']}\t" + f"distance={float(record['distance_squared']) ** 0.5:.3f}" + ) + return 0 + if args.output is None: + parser.error("output is required unless --describe is used") + temporary = args.output.with_name(args.output.name + ".tmp") + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.input.open("rb") as source, temporary.open("wb") as target: + target.write(MAGIC) + target.write(struct.pack(" None: + if not condition: + raise RuntimeError(message) + + +def collision_object( + name: str, + vertices: list[tuple[float, float, float]], + faces: list[tuple[int, int, int]], + material_name: str, +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.data.collections[addon.exporter.COLLISION_COLLECTION].objects.link(obj) + obj["ow_material"] = material_name + return obj + + +def main() -> None: + addon.register() + try: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + + require( + bpy.ops.skate_map.prepare_scene() == {"FINISHED"}, + "Prepare Scene operator failed", + ) + + bpy.ops.mesh.primitive_plane_add(size=20.0, location=(0.0, 0.0, 0.0)) + floor = bpy.context.object + floor.name = "TestFloor" + material = bpy.data.materials.new("TestConcrete") + floor.data.materials.append(material) + material.owned_world.roughness = 0.67 + floor.owned_world_physics.physics_type = "BOX3D_DYNAMIC" + floor.owned_world_physics.box3d_collision_shape = "BOX" + floor.owned_world_physics.box3d_density = 240.0 + floor.owned_world_physics.box3d_friction = 0.72 + floor.owned_world_physics.box3d_restitution = 0.11 + floor.owned_world_physics.box3d_linear_damping = 0.08 + floor.owned_world_physics.box3d_angular_damping = 0.21 + floor.owned_world_physics.box3d_gravity_scale = 0.9 + + alpha_image = bpy.data.images.new( + "RoadMarking_A.tga", width=1, height=1, alpha=True + ) + alpha_material = bpy.data.materials.new("RoadMarking_A") + alpha_material["ow_albedo_image"] = alpha_image.name + alpha_material["ow_alpha_mode"] = 0 + require( + addon.exporter._effective_alpha_mode(alpha_material) == 1, + "Alpha-named RGBA material was incorrectly exported opaque", + ) + alpha_material["ow_force_opaque"] = True + require( + addon.exporter._effective_alpha_mode(alpha_material) == 0, + "Explicit force-opaque override was ignored", + ) + placeholder_image = bpy.data.images.new( + "none", width=1, height=1, alpha=True + ) + alpha_material["ow_normal_image"] = placeholder_image.name + referenced_images, referenced_image_ids = ( + addon.exporter._referenced_images([alpha_material]) + ) + require( + placeholder_image not in referenced_images + and placeholder_image.as_pointer() not in referenced_image_ids + and addon.exporter._texture_id( + alpha_material, "ow_normal_image", referenced_image_ids + ) + == 0, + "Placeholder 'none' image was exported as a real texture", + ) + require( + not addon.exporter._has_nonblank_rgb(bytes((0, 0, 0, 255))) + and addon.exporter._has_nonblank_rgb(bytes((0, 1, 0, 255))), + "Package-level blank RGB detection is incorrect", + ) + + uv_only_mesh = bpy.data.meshes.new("UvOnlyRegressionMesh") + uv_only_mesh.from_pydata( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], + [], + [(0, 1, 2)], + ) + uv_only_mesh.update() + uv_only = uv_only_mesh.uv_layers.new(name="UVMap") + uv0, uv1 = addon.exporter._visual_uv_layers( + uv_only_mesh, "UvOnlyRegression" + ) + require( + uv0 == uv_only and uv1 == uv_only, + "UVMap-only unlit mesh did not reuse its primary UV stream", + ) + + def duplicate_surface_mask(opposite_winding: bool): + mesh = bpy.data.meshes.new("DuplicateSurfaceRegressionMesh") + mesh.from_pydata( + [ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 0.02), + (1.0, 0.0, 0.02), + (1.0, 1.0, 0.02), + (0.0, 1.0, 0.02), + ], + [], + [ + (0, 1, 2), + (0, 2, 3), + ( + (4, 7, 6) + if opposite_winding + else (4, 5, 6) + ), + ( + (4, 6, 5) + if opposite_winding + else (4, 6, 7) + ), + ], + ) + mesh.materials.append(material) + mesh.materials.append(material) + mesh.polygons[0].material_index = 0 + mesh.polygons[1].material_index = 0 + mesh.polygons[2].material_index = 1 + mesh.polygons[3].material_index = 1 + mesh.update() + mesh.calc_loop_triangles() + positions = numpy.empty( + len(mesh.vertices) * 3, dtype=numpy.float32 + ).reshape((-1, 3)) + mesh.vertices.foreach_get("co", positions.reshape(-1)) + loop_vertices = numpy.empty( + len(mesh.loops), dtype=numpy.int32 + ) + mesh.loops.foreach_get("vertex_index", loop_vertices) + triangle_loops = numpy.empty( + len(mesh.loop_triangles) * 3, dtype=numpy.int32 + ).reshape((-1, 3)) + triangle_polygons = numpy.empty( + len(mesh.loop_triangles), dtype=numpy.int32 + ) + mesh.loop_triangles.foreach_get( + "loops", triangle_loops.reshape(-1) + ) + mesh.loop_triangles.foreach_get( + "polygon_index", triangle_polygons + ) + return addon.exporter._duplicate_visual_surface_keep_mask( + mesh, + loop_vertices[triangle_loops], + triangle_polygons, + positions, + ) + + require( + int(duplicate_surface_mask(False).sum()) == 2, + "Same-facing near-duplicate visual surface was not collapsed", + ) + require( + int(duplicate_surface_mask(True).sum()) == 4, + "Intentional opposite-facing visual surface was removed", + ) + + require( + bpy.ops.skate_map.assign_selected(role="VISUAL") == {"FINISHED"}, + "Visual assignment failed", + ) + require( + bpy.ops.skate_map.assign_selected(role="COLLISION") == {"FINISHED"}, + "Collision assignment failed", + ) + floor["ow_upward_surface"] = True + require( + bpy.ops.skate_map.create_uv_layers() == {"FINISHED"}, + "UV layer helper failed", + ) + # Imported meshes can retain an empty material slot that no polygon + # uses. The vectorized exporter must validate referenced slots only, + # matching the scalar path, instead of rejecting harmless source data. + bpy.context.view_layer.objects.active = floor + floor.select_set(True) + require( + bpy.ops.object.material_slot_add() == {"FINISHED"}, + "Could not create an unused empty material slot", + ) + require( + len(floor.material_slots) == 2 + and floor.material_slots[1].material is None + and all(polygon.material_index == 0 for polygon in floor.data.polygons), + "Unused empty material slot regression setup is invalid", + ) + + # Independently editable maps commonly keep a low-detail collision + # proxy separate from the rendered Blender object. The explicit owner + # must associate that proxy's triangle range with the visual MOBJ + # record without requiring both roles to share one Blender object. + proxy_mesh = bpy.data.meshes.new("ProxyOwnedVisualMesh") + proxy_mesh.from_pydata( + [(20.0, 0.0, 0.0), (21.0, 0.0, 0.0), (20.0, 1.0, 0.0)], + [], + [(0, 1, 2)], + ) + proxy_mesh.update() + proxy_material = material.copy() + proxy_material.name = "ProxyOwnedMaterial" + proxy_mesh.materials.append(proxy_material) + proxy_mesh.uv_layers.new(name="UVMap") + proxy_mesh.uv_layers.new(name="Lightmap") + proxy_visual = bpy.data.objects.new( + "ProxyOwnedVisual", proxy_mesh + ) + bpy.data.collections[ + addon.exporter.NO_COLLISION_COLLECTION + ].objects.link(proxy_visual) + proxy_collision = collision_object( + "ProxyOwnedCollision", + [(20.0, 0.0, 0.0), (21.0, 0.0, 0.0), (20.0, 1.0, 0.0)], + [(0, 1, 2)], + proxy_material.name, + ) + proxy_owner = proxy_collision.data.attributes.new( + addon.exporter.EDITOR_COLLISION_OWNER_ATTRIBUTE, + type="INT", + domain="FACE", + ) + proxy_owner_id = int( + addon.exporter._stable_object_id(proxy_visual.name_full) + ) + proxy_owner.data[0].value = ( + proxy_owner_id + if proxy_owner_id < 0x80000000 + else proxy_owner_id - 0x100000000 + ) + + # Retail imports carry their authored frame as hidden point + # attributes. Verify that it takes precedence over Blender tangent + # generation while the ordinary TestFloor above still exercises the + # custom-map fallback. + retail_mesh = bpy.data.meshes.new("RetailFrameMesh") + retail_mesh.from_pydata( + [(100.0, 0.0, 0.0), (101.0, 0.0, 0.0), (100.0, 1.0, 0.0)], + [], + [(0, 1, 2)], + ) + retail_mesh.update() + retail_mesh.materials.append(material) + retail_mesh.uv_layers.new(name="UVMap") + retail_mesh.uv_layers.new(name="Lightmap") + retail_normal = retail_mesh.attributes.new( + addon.exporter.RETAIL_NORMAL_ATTRIBUTE, + type="FLOAT_VECTOR", + domain="POINT", + ) + retail_normal.data.foreach_set( + "vector", (0.0, 0.0, 1.0) * 3 + ) + retail_tangent = retail_mesh.attributes.new( + addon.exporter.RETAIL_TANGENT_ATTRIBUTE, + type="FLOAT_VECTOR", + domain="POINT", + ) + retail_tangent.data.foreach_set( + "vector", (1.0, 0.0, 0.0) * 3 + ) + retail_handedness = retail_mesh.attributes.new( + addon.exporter.RETAIL_HANDEDNESS_ATTRIBUTE, + type="FLOAT", + domain="POINT", + ) + retail_handedness.data.foreach_set("value", (-1.0,) * 3) + retail_frame = bpy.data.objects.new("RetailFrame", retail_mesh) + retail_frame["ow_physics_type"] = "PRESENTATION_ONLY" + retail_frame["ow_editor_editable"] = False + bpy.data.collections[ + addon.exporter.VISUAL_COLLECTION + ].objects.link(retail_frame) + + # Old add-on versions could leave imported scale-reference meshes in + # both export collections. Existing scenes must ignore them without + # requiring the author to repair collection membership manually. + helper_mesh = bpy.data.meshes.new("character_size_mesh") + helper_mesh.from_pydata( + [(0.0, 0.0, 0.0), (0.0, 0.0, 2.0), (0.25, 0.0, 0.0)], + [], + [(0, 1, 2)], + ) + helper_mesh.update() + helper = bpy.data.objects.new("character_size", helper_mesh) + bpy.data.collections[ + addon.exporter.VISUAL_COLLECTION + ].objects.link(helper) + bpy.data.collections[ + addon.exporter.COLLISION_COLLECTION + ].objects.link(helper) + + cleanup_proxy = collision_object( + "CollisionCleanupRegression", + [ + (30.0, 0.0, 0.0), + (31.0, 0.0, 0.0), + (30.0, 1.0, 0.0), + # A separate vertex set with identical positions verifies + # position-based duplicate cleanup. + (30.0, 0.0, 0.0), + (31.0, 0.0, 0.0), + (30.0, 1.0, 0.0), + # Collinear source geometry must be skipped without editing + # this mesh or any visual UVs. + (35.0, 0.0, 0.0), + (36.0, 0.0, 0.0), + (37.0, 0.0, 0.0), + ], + [(0, 1, 2), (3, 4, 5), (6, 7, 8)], + material.name, + ) + + retail_two_sided = collision_object( + "RetailTwoSidedRegression", + [ + (50.0, 0.0, 0.0), + (51.0, 0.0, 0.0), + (50.0, 1.0, 0.0), + ], + [(0, 1, 2), (2, 1, 0)], + material.name, + ) + retail_two_sided["ow_preserve_opposite_wound_collision"] = True + retail_two_sided["ow_preserve_retail_edge_codes"] = True + expected_retail_codes = ( + (26, 90, 98), + (34, 88, 122), + ) + for corner, attribute_name in enumerate( + addon.exporter.RETAIL_EDGE_CODE_ATTRIBUTES + ): + attribute = retail_two_sided.data.attributes.new( + attribute_name, + type="INT", + domain="FACE", + ) + attribute.data.foreach_set( + "value", + [codes[corner] for codes in expected_retail_codes], + ) + retail_triangles, retail_audit = ( + addon.exporter.audit_collision_geometry([retail_two_sided]) + ) + require( + len(retail_triangles) == 2 + and retail_audit.skipped_duplicates == 0 + and tuple( + triangle[-1] for triangle in retail_triangles + ) + == expected_retail_codes, + "Retail reverse-wound collision metadata was discarded", + ) + + downward_a = collision_object( + "WrongFacingA", + [(40.0, 0.0, 0.0), (40.0, 1.0, 0.0), (41.0, 0.0, 0.0)], + [(0, 1, 2)], + material.name, + ) + downward_b = collision_object( + "WrongFacingB", + [(45.0, 0.0, 0.0), (45.0, 1.0, 0.0), (46.0, 0.0, 0.0)], + [(0, 1, 2)], + material.name, + ) + downward_a["ow_upward_surface"] = True + downward_b["ow_upward_surface"] = True + _triangles, wrong_facing_audit = ( + addon.exporter.audit_collision_geometry( + [downward_a, downward_b] + ) + ) + require( + sum( + "face downward or vertically" in issue + for issue in wrong_facing_audit.issues + ) + == 2, + "Collision audit did not report every bad mesh in one pass", + ) + for obj in (downward_a, downward_b): + mesh = obj.data + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + + scene = bpy.context.scene + point_data = bpy.data.lights.new("TestPointData", type="POINT") + point_data.color = (0.15, 0.45, 1.0) + point_data.energy = 1200.0 + point_data.cutoff_distance = 12.0 + point = bpy.data.objects.new("TestPoint", point_data) + scene.collection.objects.link(point) + point.location = (-2.0, 0.0, 3.0) + + spot_data = bpy.data.lights.new("TestSpotData", type="SPOT") + spot_data.color = (1.0, 0.18, 0.08) + spot_data.energy = 1600.0 + spot_data.cutoff_distance = 16.0 + spot_data.spot_size = 0.9 + spot_data.spot_blend = 0.35 + spot = bpy.data.objects.new("TestSpot", spot_data) + scene.collection.objects.link(spot) + spot.location = (2.0, -2.0, 4.0) + + area_data = bpy.data.lights.new("TestAreaData", type="AREA") + area_data.color = (0.35, 1.0, 0.25) + area_data.energy = 900.0 + area_data.cutoff_distance = 10.0 + area_data.shape = "RECTANGLE" + area_data.size = 2.0 + area_data.size_y = 1.0 + area = bpy.data.objects.new("TestArea", area_data) + scene.collection.objects.link(area) + area.location = (0.0, 3.0, 3.0) + area.rotation_euler = (0.35, 0.0, math.pi) + + sun_data = bpy.data.lights.new("TestSunData", type="SUN") + sun_data.color = (1.0, 0.72, 0.42) + sun_data.energy = 2.5 + sun = bpy.data.objects.new("TestSun", sun_data) + scene.collection.objects.link(sun) + sun.rotation_euler = (0.7, 0.0, -0.5) + + scene.cursor.location = (0.0, 0.0, 1.0) + require( + bpy.ops.skate_map.set_spawn() == {"FINISHED"}, + "Spawn helper failed", + ) + + output = Path(tempfile.gettempdir()) / "addon_workflow_test.skate" + cache = output.with_name(output.name + ".export-cache.json") + for path in (output, cache): + path.unlink(missing_ok=True) + + settings = scene.owned_world + settings.map_name = "Addon Workflow Test" + settings.output_path = str(output) + settings.cycle_seconds = 30.0 + settings.cycle_ping_pong = True + settings.start_hour = 8.0 + settings.end_hour = 18.0 + settings.dynamic_lighting_enabled_by_default = False + + # AI routes are experimental and optional. Their collection must not + # be required for validation or export. + npc_collection = bpy.data.collections.get( + addon.exporter.NPC_PATH_COLLECTION + ) + require(npc_collection is not None, "Prepare Scene missed NPC paths") + bpy.data.collections.remove(npc_collection) + grind_collection = bpy.data.collections.get( + addon.exporter.GRIND_COLLECTION + ) + require(grind_collection is not None, "Prepare Scene missed grinds") + bpy.data.collections.remove(grind_collection) + + require( + bpy.ops.skate_map.validate() == {"FINISHED"}, + settings.validation_details or "Validation failed", + ) + require( + "zero-area collision triangle" in settings.validation_details + and "duplicate collision triangle" in settings.validation_details, + "Validation did not explain automatic collision cleanup", + ) + require( + "OW_NPC_PATHS" not in settings.validation_details, + "Optional NPC paths were reported as an export problem", + ) + require( + "OW_GRIND" not in settings.validation_details, + "Optional grind collection was reported as an export problem", + ) + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + settings.last_status, + ) + require(output.is_file(), "Quick Export did not create an SKATE") + require(cache.is_file(), "Quick Export did not create its cache") + require( + output.read_bytes()[:8] == b"SKATE15\0", + "Exported package has the wrong magic", + ) + analysis = analyze_package(output, include_payloads=True) + require( + analysis["dynamic_lighting_enabled_by_default"] is False, + "Full export did not store the map's dynamic-lighting default", + ) + require( + "MOBJ" in analysis["extension_tags"], + "Exported package did not preserve Blender object records", + ) + object_records = analysis["map_objects"] + require( + [record["name"] for record in object_records] + == ["TestFloor", "ProxyOwnedVisual"], + "Static Blender objects leaked into editable object records", + ) + require( + analysis["counts"]["indices"] + >= sum(record["index_count"] for record in object_records) + and analysis["counts"]["collision_triangles"] + >= sum( + record["collision_count"] for record in object_records + ), + "MOBJ parsing overwrote the package-level geometry counts", + ) + require( + len({record["id"] for record in object_records}) == 2 + and all(record["id"] != 0 for record in object_records), + "Blender object IDs are zero or not unique", + ) + require( + object_records[0]["index_count"] == 6 + and object_records[0]["collision_count"] == 2, + "Visual/collision ownership was not associated with TestFloor", + ) + require( + object_records[0]["physics"]["type"] == 2 + and object_records[0]["physics"]["shape"] == 0 + and abs(object_records[0]["physics"]["density"] - 240.0) + < 1.0e-5 + and abs(object_records[0]["physics"]["friction"] - 0.72) + < 1.0e-5 + and abs(object_records[0]["physics"]["restitution"] - 0.11) + < 1.0e-5 + and abs( + object_records[0]["physics"]["linear_damping"] - 0.08 + ) + < 1.0e-5 + and abs( + object_records[0]["physics"]["angular_damping"] - 0.21 + ) + < 1.0e-5 + and abs(object_records[0]["physics"]["gravity_scale"] - 0.9) + < 1.0e-5, + "Box3D object metadata did not survive export and analysis", + ) + proxy_record = next( + record + for record in object_records + if record["name"] == "ProxyOwnedVisual" + ) + require( + proxy_record["index_count"] == 3 + and proxy_record["collision_count"] == 1, + "Face-level collision ownership was not associated with its " + "visual MOBJ owner", + ) + require( + proxy_record["physics"]["type"] == 0, + "Physics was not disabled by default for an ordinary object", + ) + retail_frame_records = [] + vertex_bytes = analysis["_vertex_bytes"] + for offset in range( + 0, + len(vertex_bytes), + VERTEX_BYTES_V12, + ): + position = struct.unpack_from("<3f", vertex_bytes, offset) + if position[0] < 90.0: + continue + retail_frame_records.append( + ( + struct.unpack_from("<3f", vertex_bytes, offset + 12), + struct.unpack_from("<4b", vertex_bytes, offset + 52), + ) + ) + require( + retail_frame_records + and all( + normal == (0.0, 1.0, 0.0) + and frame == (0, 0, 127, -127) + for normal, frame in retail_frame_records + ), + "Retail tangent did not reconstruct the expected runtime binormal", + ) + collision_bytes = analysis["_collision_bytes"] + native_codes = [] + for offset in range(0, len(collision_bytes), 48): + record = struct.unpack_from("<9fII4B", collision_bytes, offset) + if record[14] == 1: + native_codes.append(tuple(record[11:14])) + require( + tuple(native_codes) == expected_retail_codes, + "SKATE package changed native retail collision edge codes", + ) + _, _, counts = addon.exporter._read_package_header(output) + collision_triangle_count = counts[4] + local_light_count = counts[-2] + npc_route_count = counts[-1] + require( + collision_triangle_count == 7, + "Degenerate or duplicate collision reached the package " + f"(got {collision_triangle_count}, expected 7)", + ) + require( + local_light_count == 3, + "Point, Spot, and Area lights were not exported", + ) + require(npc_route_count == 0, "Unexpected NPC routes were exported") + require( + abs(float(material.get("ow_roughness", 0.0)) - 0.67) < 1.0e-5, + "Material panel did not synchronize exporter metadata", + ) + require( + settings.last_status.startswith("Exported successfully"), + "Friendly export result was not retained", + ) + require( + "No mesh dissolve or UV changes are required" + in settings.validation_details, + "Quick Export did not retain collision cleanup guidance", + ) + settings.dynamic_lighting_enabled_by_default = True + settings.export_mode = "METADATA" + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Lighting-only export did not patch the dynamic-lighting default", + ) + lighting_only = analyze_package(output) + require( + lighting_only["dynamic_lighting_enabled_by_default"] is True, + "Lighting-only export left a stale dynamic-lighting default", + ) + settings.export_mode = "AUTO" + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Incremental export failed after collision sanitation", + ) + _, _, cached_counts = addon.exporter._read_package_header(output) + require( + cached_counts == counts, + "Incremental cache changed sanitized package counts", + ) + + # Authors can export a finished map with no selectable existing + # objects. Rendering, collision, materials, and other package counts + # must stay intact, while both face-level and proxy ownership metadata + # are ignored and the MOBJ table becomes empty. + settings.export_editable_objects = False + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Non-editable map export failed", + ) + non_editable = analyze_package(output, include_payloads=True) + require( + "MOBJ" in non_editable["extension_tags"] + and not non_editable["map_objects"], + "Non-editable export retained selectable map object records", + ) + require( + non_editable["counts"] == analysis["counts"], + "Non-editable export changed visual, collision, or feature counts", + ) + require( + non_editable["_collision_bytes"], + "Non-editable export discarded static collision", + ) + + # The global mode participates in the content fingerprint. Switching + # it back on must rebuild the object table instead of accepting the + # non-editable package as an incremental cache hit. + settings.export_editable_objects = True + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Re-enabling editable map objects failed", + ) + editable_again = analyze_package(output) + require( + [record["name"] for record in editable_again["map_objects"]] + == ["TestFloor", "ProxyOwnedVisual"], + "Editable object records were not restored after changing mode", + ) + + blend_path = output.with_suffix(".blend") + bpy.ops.wm.save_as_mainfile(filepath=str(blend_path)) + require(blend_path.is_file(), "Workflow test scene was not saved") + print( + "ADDON_WORKFLOW_OK", + output, + output.stat().st_size, + settings.last_status, + ) + finally: + addon.unregister() + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/vendor/university/tools/blender_owned_map/test_agent_collision_plan.py b/tools/vendor/university/tools/blender_owned_map/test_agent_collision_plan.py new file mode 100644 index 00000000..6c5bb816 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/test_agent_collision_plan.py @@ -0,0 +1,174 @@ +"""Headless regression test for explicit version 2 agent collision roles.""" + +from pathlib import Path +import sys + +import bpy + + +TOOL_ROOT = Path(__file__).resolve().parent +SCRIPT_ROOT = ( + TOOL_ROOT + / "agent_workflow" + / "sk8-auto-map" + / "scripts" +) +for path in (TOOL_ROOT, SCRIPT_ROOT): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import owned_world_material_addon as addon +from apply_map_plan import apply_plan + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def make_plane( + collection: bpy.types.Collection, + name: str, + x_offset: float, + material: bpy.types.Material | None, +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata( + [ + (x_offset - 1.0, -1.0, 0.0), + (x_offset + 1.0, -1.0, 0.0), + (x_offset + 1.0, 1.0, 0.0), + (x_offset - 1.0, 1.0, 0.0), + ], + [], + [(0, 1, 2, 3)], + ) + mesh.update() + if material is not None: + mesh.materials.append(material) + obj = bpy.data.objects.new(name, mesh) + collection.objects.link(obj) + return obj + + +def material_plan(name: str, collision: bool) -> dict: + return { + "material": name, + "audio_surface": 4, + "physics_surface": 2, + "surface_pattern": 0, + "collision_enabled": collision, + } + + +def main() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for material in list(bpy.data.materials): + bpy.data.materials.remove(material) + + source = bpy.data.collections.new("Imported_Source_Organization") + bpy.context.scene.collection.children.link(source) + grass = bpy.data.materials.new("Soft_Grass_Planter") + leaves = bpy.data.materials.new("Dense_Shrub_Foliage") + proxy_material = bpy.data.materials.new("Invisible_Collision_Material") + planter = make_plane(source, "Soft_Grass_Planter_Block", 0.0, grass) + foliage = make_plane(source, "Dense_Shrub_Foliage", 3.0, leaves) + proxy = make_plane(source, "UCX_Plaza_Proxy", 6.0, proxy_material) + proxy.hide_render = True + helper = make_plane(source, "character_size", 9.0, None) + + incomplete = { + "version": 2, + "object_roles": [ + {"object": planter.name, "role": "DEFAULT"}, + ], + } + try: + apply_plan(incomplete) + except ValueError as exc: + require( + "must classify every mesh" in str(exc), + f"Unexpected incomplete-plan error: {exc}", + ) + else: + raise RuntimeError("Version 2 accepted an incomplete mesh role plan") + + plan = { + "version": 2, + "map_name": "Agent Collision Role Test", + "materials": [ + material_plan(grass.name, True), + material_plan(leaves.name, False), + material_plan(proxy_material.name, True), + ], + "object_roles": [ + {"object": planter.name, "role": "DEFAULT"}, + {"object": foliage.name, "role": "VISUAL_ONLY"}, + {"object": proxy.name, "role": "COLLISION_ONLY"}, + {"object": helper.name, "role": "IGNORE"}, + ], + "grinds": {"enabled": False}, + } + result = apply_plan(plan) + require( + addon._object_groups(planter) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + "Agent DEFAULT role was not applied", + ) + require( + addon._object_groups(foliage) + == [addon.exporter.NO_COLLISION_COLLECTION], + "Agent VISUAL_ONLY role was not applied", + ) + require( + addon._object_groups(proxy) + == [addon.exporter.NO_PRESENTATION_COLLECTION], + "Agent COLLISION_ONLY role was not applied", + ) + require( + not addon._object_groups(helper), + "Agent IGNORE role did not remove SKATE memberships", + ) + for obj in (planter, foliage, proxy, helper): + require( + obj.name in source.objects, + f"Applying a SKATE role destroyed {obj.name}'s source collection", + ) + require( + planter.get("ow_material") == grass.name, + "DEFAULT object did not receive its explicit collision material", + ) + require( + proxy.get("ow_material") == proxy_material.name, + "COLLISION_ONLY object did not receive its explicit material", + ) + require( + bool(grass.get("ow_collision_enabled", False)) + and not bool(leaves.get("ow_collision_enabled", True)) + and bool(proxy_material.get("ow_collision_enabled", False)), + "Agent material collision decisions were not preserved", + ) + require( + not bool(grass.get("ow_auto_imported", True)), + "Agent material decision remained auto-refreshable", + ) + require( + result["object_roles"] == 4 + and result["collision_objects"] == 2, + f"Unexpected apply result: {result}", + ) + print("AGENT_COLLISION_PLAN_OK", result) + addon.unregister() + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/vendor/university/tools/blender_owned_map/test_auto_import_workflow.py b/tools/vendor/university/tools/blender_owned_map/test_auto_import_workflow.py new file mode 100644 index 00000000..dd536a58 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/test_auto_import_workflow.py @@ -0,0 +1,1227 @@ +"""Headless regression test for exporting an ordinary unconfigured .blend.""" + +from pathlib import Path +import math +import sys +import tempfile + +import bpy + + +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +import owned_world_material_addon as addon +from analyze_skate import analyze_package +from compare_skate import compare_packages + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def make_plane( + name: str, + x_offset: float, + material: bpy.types.Material | None = None, +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata( + [ + (x_offset - 1.0, -1.0, 0.0), + (x_offset + 1.0, -1.0, 0.0), + (x_offset + 1.0, 1.0, 0.0), + (x_offset - 1.0, 1.0, 0.0), + ], + [], + [(0, 1, 2, 3)], + ) + mesh.update() + if material is not None: + mesh.materials.append(material) + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def main() -> None: + addon.register() + try: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for material in list(bpy.data.materials): + bpy.data.materials.remove(material) + + image = bpy.data.images.new( + "AutoImportAlbedo", width=2, height=2, alpha=True + ) + image.pixels = [ + 0.8, 0.2, 0.1, 1.0, + 0.8, 0.2, 0.1, 1.0, + 0.8, 0.2, 0.1, 1.0, + 0.8, 0.2, 0.1, 1.0, + ] + image.pack() + require( + image.packed_file is not None, + "Packed-image cache regression setup failed", + ) + stray_image = bpy.data.images.new( + "Road_ORM_But_Unconnected", width=1, height=1, alpha=True + ) + deterministic_material = bpy.data.materials.new( + "DeterministicTextureRoles" + ) + deterministic_material.use_nodes = True + stray_node = deterministic_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + stray_node.image = stray_image + addon._auto_configure_material( + deterministic_material, generate_maps=True + ) + require( + not deterministic_material.get("ow_albedo_image", "") + and not deterministic_material.get("ow_orm_image", ""), + "An unconnected texture was assigned from its filename", + ) + height_image = bpy.data.images.new( + "HeightIsNotNormal", width=2, height=2, alpha=False + ) + bump_material = bpy.data.materials.new("BumpCompatibility") + bump_material.use_nodes = True + bump_shader = next( + node + for node in bump_material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + bump = bump_material.node_tree.nodes.new("ShaderNodeBump") + height = bump_material.node_tree.nodes.new("ShaderNodeTexImage") + height.image = height_image + bump_material.node_tree.links.new( + height.outputs["Color"], bump.inputs["Height"] + ) + bump_material.node_tree.links.new( + bump.outputs["Normal"], bump_shader.inputs["Normal"] + ) + addon._auto_configure_material( + bump_material, generate_maps=True + ) + require( + not bump_material.get("ow_normal_image", ""), + "A scalar Blender Bump height map was mislabelled as an RGB " + "tangent normal map", + ) + procedural_emission = bpy.data.materials.new( + "UnsupportedBlackProceduralEmission" + ) + procedural_emission.use_nodes = True + procedural_shader = next( + node + for node in procedural_emission.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + black_rgb = procedural_emission.node_tree.nodes.new("ShaderNodeRGB") + black_rgb.outputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0) + procedural_emission.node_tree.links.new( + black_rgb.outputs["Color"], + procedural_shader.inputs["Emission Color"], + ) + procedural_shader.inputs["Emission Strength"].default_value = 1.0 + addon._auto_configure_material( + procedural_emission, generate_maps=True + ) + procedural_emissive_value = float( + procedural_emission.get("ow_emissive", -1.0) + ) + procedural_emissive_image = str( + procedural_emission.get("ow_emissive_image", "") + ) + require( + abs(procedural_emissive_value) < 1.0e-6 + and not procedural_emissive_image, + "Unsupported black procedural emission generated a black texture " + f"(strength={procedural_emissive_value}, " + f"image={procedural_emissive_image!r})", + ) + material = bpy.data.materials.new("Concrete_AutoImport") + material.use_nodes = True + shader = next( + node + for node in material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + shader.inputs["Roughness"].default_value = 0.63 + shader.inputs["Metallic"].default_value = 0.24 + texture_group = bpy.data.node_groups.new( + "ImportedAlbedoNodeGroup", "ShaderNodeTree" + ) + texture_group.interface.new_socket( + name="Color", + in_out="OUTPUT", + socket_type="NodeSocketColor", + ) + group_output = texture_group.nodes.new("NodeGroupOutput") + texture = texture_group.nodes.new("ShaderNodeTexImage") + texture.image = image + explicit_uv = texture_group.nodes.new("ShaderNodeUVMap") + explicit_uv.uv_map = "TEXCOORD" + mapping = texture_group.nodes.new("ShaderNodeMapping") + mapping.vector_type = "POINT" + mapping.inputs["Scale"].default_value = (2.0, 3.0, 1.0) + mapping.inputs["Location"].default_value = (0.25, -0.5, 0.0) + texture_group.links.new( + explicit_uv.outputs["UV"], mapping.inputs["Vector"] + ) + texture_group.links.new( + mapping.outputs["Vector"], texture.inputs["Vector"] + ) + texture_group.links.new( + texture.outputs["Color"], group_output.inputs["Color"] + ) + group_node = material.node_tree.nodes.new("ShaderNodeGroup") + group_node.node_tree = texture_group + material.node_tree.links.new( + group_node.outputs["Color"], shader.inputs["Base Color"] + ) + + nested_image = bpy.data.images.new( + "NestedShaderAlbedo", width=2, height=2, alpha=True + ) + nested_image.pixels = [0.2, 0.5, 0.9, 1.0] * 4 + nested_image.pack() + nested_tree = bpy.data.node_groups.new( + "ImportedNestedShader", "ShaderNodeTree" + ) + nested_tree.interface.new_socket( + name="TextureColor", + in_out="INPUT", + socket_type="NodeSocketColor", + ) + nested_tree.interface.new_socket( + name="BSDF", + in_out="OUTPUT", + socket_type="NodeSocketShader", + ) + nested_input = nested_tree.nodes.new("NodeGroupInput") + nested_output = nested_tree.nodes.new("NodeGroupOutput") + nested_shader = nested_tree.nodes.new("ShaderNodeBsdfPrincipled") + nested_tree.links.new( + nested_input.outputs["TextureColor"], + nested_shader.inputs["Base Color"], + ) + nested_tree.links.new( + nested_shader.outputs["BSDF"], nested_output.inputs["BSDF"] + ) + nested_material = bpy.data.materials.new("NestedImportedShader") + nested_material.use_nodes = True + nested_material.node_tree.nodes.clear() + nested_material_output = nested_material.node_tree.nodes.new( + "ShaderNodeOutputMaterial" + ) + nested_group = nested_material.node_tree.nodes.new( + "ShaderNodeGroup" + ) + nested_group.node_tree = nested_tree + nested_texture = nested_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + nested_texture.image = nested_image + nested_material.node_tree.links.new( + nested_texture.outputs["Color"], + nested_group.inputs["TextureColor"], + ) + nested_material.node_tree.links.new( + nested_group.outputs["BSDF"], + nested_material_output.inputs["Surface"], + ) + addon._auto_configure_material( + nested_material, generate_maps=True + ) + require( + nested_material.get("ow_albedo_image") == nested_image.name, + "An image feeding a Principled shader through a group input was " + "not resolved from the connected Blender graph", + ) + + layer_small = bpy.data.images.new( + "LayeredGroundSmallColor", width=2, height=2, alpha=True + ) + layer_large = bpy.data.images.new( + "LayeredGroundLargeColor", width=4, height=4, alpha=True + ) + layer_mask = bpy.data.images.new( + "LayeredGroundHeightMask", width=8, height=8, alpha=True + ) + for layer_image, colour in ( + (layer_small, (0.15, 0.25, 0.35, 1.0)), + (layer_large, (0.55, 0.45, 0.25, 1.0)), + (layer_mask, (0.25, 0.25, 0.25, 1.0)), + ): + layer_image.pixels = ( + list(colour) * (layer_image.size[0] * layer_image.size[1]) + ) + layer_image.pack() + layer_image.use_fake_user = True + layered_material = bpy.data.materials.new("LayeredGround") + layered_material.use_nodes = True + layered_shader = next( + node + for node in layered_material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + mix = layered_material.node_tree.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + small_node = layered_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + small_node.image = layer_small + large_node = layered_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + large_node.image = layer_large + mask_node = layered_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + mask_node.image = layer_mask + mix_factor = next( + socket + for socket in mix.inputs + if socket.name == "Factor" + and socket.identifier.endswith("_Float") + ) + mix_a = next( + socket + for socket in mix.inputs + if socket.name == "A" + and socket.identifier.endswith("_Color") + ) + mix_b = next( + socket + for socket in mix.inputs + if socket.name == "B" + and socket.identifier.endswith("_Color") + ) + mix_result = next( + socket + for socket in mix.outputs + if socket.name == "Result" + and socket.identifier.endswith("_Color") + ) + layered_material.node_tree.links.new( + mask_node.outputs["Color"], mix_factor + ) + layered_material.node_tree.links.new( + small_node.outputs["Color"], mix_a + ) + layered_material.node_tree.links.new( + large_node.outputs["Color"], mix_b + ) + layered_material.node_tree.links.new( + mix_result, layered_shader.inputs["Base Color"] + ) + addon._auto_configure_material( + layered_material, generate_maps=True + ) + require( + layered_material.get("ow_albedo_image") == layer_small.name + and layered_material.get("ow_secondary_albedo_image") + == layer_large.name + and layered_material.get("ow_blend_mask_image") + == layer_mask.name, + "A supported Blender Mix material did not retain both visible " + "colour layers and its factor texture", + ) + require( + int(layered_material.get("ow_blend_mask_channel", -1)) == 0, + "A colour factor texture did not retain luminance semantics", + ) + + complex_image = bpy.data.images.new( + "ObjectTintComplexAlbedo", width=2, height=2, alpha=True + ) + complex_image.pixels = [ + 0.4, 0.5, 0.6, 0.0, + 0.4, 0.5, 0.6, 1.0, + 0.4, 0.5, 0.6, 0.25, + 0.4, 0.5, 0.6, 0.75, + ] + complex_image.pack() + complex_material = bpy.data.materials.new("ObjectTintComplex") + complex_material.use_nodes = True + complex_shader = next( + node + for node in complex_material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + # A Base Color bake must capture the colour graph itself. Cycles' + # diffuse pass turns a fully metallic Principled surface black. + complex_shader.inputs["Metallic"].default_value = 1.0 + complex_texture = complex_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + complex_texture.image = complex_image + object_info = complex_material.node_tree.nodes.new( + "ShaderNodeObjectInfo" + ) + multiply = complex_material.node_tree.nodes.new( + "ShaderNodeMixRGB" + ) + multiply.blend_type = "MULTIPLY" + multiply.inputs["Fac"].default_value = 1.0 + vertex_color = complex_material.node_tree.nodes.new( + "ShaderNodeVertexColor" + ) + vertex_color.layer_name = "TINT" + vertex_multiply = complex_material.node_tree.nodes.new( + "ShaderNodeMixRGB" + ) + vertex_multiply.blend_type = "MULTIPLY" + vertex_multiply.inputs["Fac"].default_value = 1.0 + complex_material.node_tree.links.new( + complex_texture.outputs["Color"], multiply.inputs["Color1"] + ) + complex_material.node_tree.links.new( + object_info.outputs["Color"], multiply.inputs["Color2"] + ) + complex_material.node_tree.links.new( + multiply.outputs["Color"], vertex_multiply.inputs["Color1"] + ) + complex_material.node_tree.links.new( + vertex_color.outputs["Color"], + vertex_multiply.inputs["Color2"], + ) + complex_material.node_tree.links.new( + vertex_multiply.outputs["Color"], + complex_shader.inputs["Base Color"], + ) + complex_material.node_tree.links.new( + complex_texture.outputs["Alpha"], + complex_shader.inputs["Alpha"], + ) + addon._auto_configure_material( + complex_material, generate_maps=True + ) + require( + bool( + complex_material.get( + "ow_requires_base_color_bake", False + ) + ) + and bool( + complex_material.get("ow_uses_object_color", False) + ), + "Unsupported Base Color graph was not marked for flattening or " + "its Object Info colour dependency was lost", + ) + + packed_mask_image = bpy.data.images.new( + "OpaqueColorWithPackedAlpha", width=2, height=2, alpha=True + ) + packed_mask_image.pixels = [ + 0.4, 0.5, 0.6, 0.0, + 0.4, 0.5, 0.6, 1.0, + 0.4, 0.5, 0.6, 0.25, + 0.4, 0.5, 0.6, 0.75, + ] + packed_mask_material = bpy.data.materials.new( + "OpaquePackedAlpha" + ) + packed_mask_material.use_nodes = True + packed_mask_shader = next( + node + for node in packed_mask_material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + packed_mask_node = packed_mask_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + packed_mask_node.image = packed_mask_image + packed_mask_material.node_tree.links.new( + packed_mask_node.outputs["Color"], + packed_mask_shader.inputs["Base Color"], + ) + addon._auto_configure_material( + packed_mask_material, generate_maps=True + ) + require( + int(packed_mask_material.get("ow_alpha_mode", -1)) == 0 + and not packed_mask_shader.inputs["Alpha"].links, + "Packed albedo alpha was incorrectly invented as transparency", + ) + + glass_material = bpy.data.materials.new("PhysicalGlassFallback") + glass_material.use_nodes = True + glass_tree = glass_material.node_tree + glass_output = next( + node for node in glass_tree.nodes if node.type == "OUTPUT_MATERIAL" + ) + for link in list(glass_output.inputs["Surface"].links): + glass_tree.links.remove(link) + glass_shader = glass_tree.nodes.new("ShaderNodeBsdfGlass") + glass_shader.inputs["Color"].default_value = ( + 0.2, + 0.55, + 0.8, + 1.0, + ) + glass_shader.inputs["Roughness"].default_value = 0.1 + glass_object_info = glass_tree.nodes.new("ShaderNodeObjectInfo") + glass_tree.links.new( + glass_object_info.outputs["Color"], + glass_shader.inputs["Color"], + ) + glass_tree.links.new( + glass_shader.outputs["BSDF"], + glass_output.inputs["Surface"], + ) + addon._auto_configure_material( + glass_material, generate_maps=True + ) + glass_image = bpy.data.images.get( + str(glass_material.get("ow_albedo_image", "")) + ) + require( + glass_image is not None + and glass_image.get("ow_generated_map") == "GLASS" + and int(glass_material.get("ow_alpha_mode", -1)) == 2, + "A physical Blender Glass BSDF did not receive the basic " + "transparent runtime fallback", + ) + glass_pixels = list(glass_image.pixels[:]) + require( + all( + abs(glass_pixels[index + 3] - 0.32) < 0.01 + for index in range(0, len(glass_pixels), 4) + ) + and min(glass_pixels[0::4]) > 0.1 + and min(glass_pixels[2::4]) > 0.5, + "The basic Glass BSDF fallback lost its tint or opacity", + ) + require( + tuple(glass_material.get("ow_display_color", ())) + == (1.0, 1.0, 1.0) + and not bool( + glass_material.get("ow_uses_object_color", True) + ), + "Unsupported custom Glass BSDF tinting leaked into the neutral " + "runtime fallback", + ) + + floor = make_plane("OrdinaryVisibleFloor", 0.0, material) + make_plane("LayeredGroundPatch", 3.0, layered_material) + complex_white = make_plane( + "ObjectTintComplexWhite", 13.0, complex_material + ) + complex_blue = make_plane( + "ObjectTintComplexBlue", 16.0, complex_material + ) + complex_white.color = (1.0, 1.0, 1.0, 1.0) + complex_blue.color = (0.25, 0.5, 0.75, 1.0) + make_plane("PhysicalGlassPanel", 19.0, glass_material) + for obj, minimum_u, maximum_u in ( + (complex_white, 0.0, 0.5), + (complex_blue, 0.5, 1.0), + ): + tint = obj.data.color_attributes.new( + name="TINT", + type="FLOAT_COLOR", + domain="CORNER", + ) + for item in tint.data: + item.color = (1.0, 1.0, 1.0, 1.0) + uv = obj.data.uv_layers.new(name="UVMap") + for item, coordinate in zip( + uv.data, + ( + (minimum_u, 0.0), + (maximum_u, 0.0), + (maximum_u, 1.0), + (minimum_u, 1.0), + ), + strict=True, + ): + item.uv = coordinate + imported_uv = floor.data.uv_layers.new(name="TEXCOORD") + imported_uv.data[0].uv = (-1.7014118e38, 0.0) + cutout_image = bpy.data.images.new( + "ImportedLeafCutout", width=2, height=2, alpha=True + ) + cutout_image.pixels = [ + 0.1, 0.6, 0.2, 0.0, + 0.1, 0.6, 0.2, 1.0, + 0.1, 0.6, 0.2, 0.0, + 0.1, 0.6, 0.2, 1.0, + ] + cutout_image.pack() + cutout_material = bpy.data.materials.new("ImportedLeaf") + cutout_material.use_nodes = True + cutout_shader = next( + node + for node in cutout_material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + ) + cutout_texture = cutout_material.node_tree.nodes.new( + "ShaderNodeTexImage" + ) + cutout_texture.image = cutout_image + cutout_material.node_tree.links.new( + cutout_texture.outputs["Color"], + cutout_shader.inputs["Base Color"], + ) + cutout_material.node_tree.links.new( + cutout_texture.outputs["Alpha"], + cutout_shader.inputs["Alpha"], + ) + # Simulate a scene prepared by an older addon: Skate semantics exist, + # but there is no importer-owned or field-authored marker yet. + cutout_material["ow_audio_surface"] = 55 + cutout_material["ow_physics_surface"] = 3 + cutout_material["ow_alpha_mode"] = 0 + leaf_card = make_plane("ImportedLeafCard", 3.0, cutout_material) + shared_leaf_card = make_plane( + "ImportedLeafCard_AlternateRole", 4.0, cutout_material + ) + floor.location = (1.25, -0.75, 0.5) + floor.rotation_euler = (0.1, 0.2, 0.3) + floor.scale = (1.2, 0.8, 1.1) + solidify = floor.modifiers.new("ExportedSolidify", "SOLIDIFY") + solidify.thickness = 0.2 + collider = make_plane("FloorCollider", 5.0) + grass_material = bpy.data.materials.new("Soft_Grass_Planter") + grass_planter = make_plane( + "Soft_Grass_Planter_Block", 7.5, grass_material + ) + foliage_material = bpy.data.materials.new("Dense_Shrub_Foliage") + foliage = make_plane( + "Dense_Shrub_Foliage_Decoration", 9.0, foliage_material + ) + scale_reference = make_plane("character_size", 10.0) + lod_parent = bpy.data.objects.new("ImportedTree", None) + bpy.context.scene.collection.objects.link(lod_parent) + lod0 = make_plane("ImportedTree_LOD0", 11.0, material) + lod1 = make_plane("ImportedTree_LOD1", 11.0, material) + lod0.parent = lod_parent + lod1.parent = lod_parent + legacy_visual = bpy.data.collections.new( + addon.exporter.LEGACY_VISUAL_COLLECTION + ) + legacy_collision = bpy.data.collections.new( + addon.exporter.LEGACY_COLLISION_COLLECTION + ) + bpy.context.scene.collection.children.link(legacy_visual) + bpy.context.scene.collection.children.link(legacy_collision) + legacy_visual.objects.link(floor) + legacy_collision.objects.link(floor) + legacy_collision.objects.link(collider) + + fake_light = bpy.data.objects.new("Point Light Imported Empty", None) + bpy.context.scene.collection.objects.link(fake_light) + + light_data = bpy.data.lights.new("RealPointData", type="POINT") + light_data.energy = 800.0 + real_light = bpy.data.objects.new("RealPoint", light_data) + bpy.context.scene.collection.objects.link(real_light) + real_light.location = (0.0, 0.0, 3.0) + exported_light = next( + light + for light in addon.exporter._export_local_lights() + if light.name == real_light.name + ) + expected_radius = math.sqrt( + (light_data.energy / 100.0) + / bpy.context.scene.eevee.light_threshold + ) + require( + abs(exported_light.influence_radius - expected_radius) < 1.0e-4 + and abs(exported_light.influence_radius - 40.0) > 1.0, + "A disabled Blender Custom Distance exported its inactive " + "40-metre cutoff instead of Eevee's power-based radius", + ) + + scene = bpy.context.scene + scene.cursor.location = (0.0, 0.0, 1.0) + require( + bpy.ops.skate_map.set_spawn() == {"FINISHED"}, + "Spawn helper failed before automatic scene preparation", + ) + spawn = bpy.data.objects[addon.exporter.SPAWN_OBJECT] + require(spawn.type == "MESH", "Spawn locator is not a movable mesh") + require( + abs(spawn.dimensions.x - 4.0) < 1.0e-5 + and abs(spawn.dimensions.y - 4.0) < 1.0e-5, + "Spawn locator is not a 4x4 metre pad", + ) + require( + tuple(spawn.location) == (0.0, 0.0, 0.0), + "Spawn locator still followed the 3D cursor", + ) + + output = Path(tempfile.gettempdir()) / "auto_import_workflow.skate" + cache = output.with_name(output.name + ".export-cache.json") + for path in (output, cache): + path.unlink(missing_ok=True) + scene.owned_world.map_name = "Automatic Import Test" + scene.owned_world.output_path = str(output) + + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + scene.owned_world.validation_details + or scene.owned_world.last_status, + ) + require(output.is_file(), "Automatic export did not create a package") + baked_base = bpy.data.images.get( + str( + complex_material.get( + "ow_baked_base_color_image", "" + ) + ) + ) + require( + baked_base is not None + and baked_base.packed_file is not None + and baked_base.use_fake_user + and complex_material.get("ow_albedo_image") + == baked_base.name, + "Complex Blender Base Color was not flattened into a persistent " + "export texture", + ) + baked_pixels = list(baked_base.pixels[:]) + require( + min(baked_pixels[3::4]) < 0.05 + and max(baked_pixels[3::4]) > 0.95 + and bool(baked_base.get("ow_bake_alpha_preserved", False)) + and all( + baked_pixels[index] + or baked_pixels[index + 1] + or baked_pixels[index + 2] + for index in range(0, len(baked_pixels), 4) + ), + "Complex Base Color lost its metallic colour graph, neutral " + "vertex TINT, or complete texture-domain coverage", + ) + analyzed = analyze_package(output, include_payloads=True) + exported_glass = next( + ( + record + for record in analyzed["_materials"] + if str(record["name"]).startswith( + "PhysicalGlassFallback" + ) + ), + None, + ) + require( + exported_glass is not None + and int(exported_glass["alpha_mode"]) == 2, + "The physical Glass BSDF fallback was not exported as a " + "transparent material", + ) + require( + tuple( + round(float(value), 3) + for value in exported_glass["display_color"] + ) + == (1.0, 1.0, 1.0), + "The exported glass fallback retained an unsupported object " + "colour multiplier", + ) + object_tint_variants = [ + record + for record in analyzed["_materials"] + if str(record["name"]).startswith("ObjectTintComplex") + ] + require( + len(object_tint_variants) == 2, + "One shared Blender material with two Object Info colours did " + "not export as two lightweight material variants", + ) + exported_colours = { + tuple(round(float(value), 3) for value in record["display_color"]) + for record in object_tint_variants + } + require( + exported_colours + == {(1.0, 1.0, 1.0), (0.25, 0.5, 0.75)}, + "Object Info colour variants did not retain their Blender " + f"multipliers: {sorted(exported_colours)!r}", + ) + default_group = bpy.data.collections.get( + addon.exporter.PRESENTATION_COLLISION_COLLECTION + ) + collider_group = bpy.data.collections.get( + addon.exporter.NO_PRESENTATION_COLLECTION + ) + no_collision_group = bpy.data.collections.get( + addon.exporter.NO_COLLISION_COLLECTION + ) + require(default_group is not None, "Automatic export missed Group 1") + require(collider_group is not None, "Automatic export missed Group 2") + require(no_collision_group is not None, "Automatic export missed Group 3") + require( + bpy.data.collections.get( + addon.exporter.LEGACY_VISUAL_COLLECTION + ) + is None + and bpy.data.collections.get( + addon.exporter.LEGACY_COLLISION_COLLECTION + ) + is None, + "Legacy role collections were not cleaned up after migration", + ) + require( + addon._object_groups(floor) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + "Ordinary mesh was not assigned exclusively to Group 1", + ) + require( + addon._object_groups(collider) + == [addon.exporter.NO_PRESENTATION_COLLECTION], + "Explicit collider was not assigned exclusively to Group 2", + ) + for semantic_name_object, semantic_material in ( + (grass_planter, grass_material), + (foliage, foliage_material), + ): + require( + addon._object_groups(semantic_name_object) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + f"{semantic_name_object.name} was guessed from its name " + "instead of receiving the conservative Group 1 baseline", + ) + require( + bool( + semantic_material.get("ow_collision_enabled", False) + ), + f"{semantic_material.name} was guessed non-colliding from " + "its name", + ) + require( + not addon._object_groups(scale_reference), + "Character scale helper was incorrectly exported as map geometry", + ) + require( + not addon._object_groups(lod1), + "Secondary imported LOD was incorrectly exported", + ) + require( + floor.name in default_group.all_objects + and collider.name in collider_group.all_objects, + "Five-group automatic classification is incomplete", + ) + scene.owned_world.material_list_index = bpy.data.materials.find( + material.name + ) + require( + floor.select_get() + and bpy.context.view_layer.objects.active == floor + and not collider.select_get(), + "Clicking a material-list row did not select its scene meshes", + ) + require( + bpy.ops.skate_map.set_material_group( + group=addon.exporter.NO_COLLISION_COLLECTION + ) + == {"FINISHED"} + and addon._object_groups(floor) + == [addon.exporter.NO_COLLISION_COLLECTION], + "Material-list Group 3 button did not move its mesh users", + ) + require( + bpy.ops.skate_map.set_material_group( + group=addon.exporter.PRESENTATION_COLLISION_COLLECTION + ) + == {"FINISHED"} + and addon._object_groups(floor) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + "Material-list Group 1 button did not restore its mesh users", + ) + no_collision_group.objects.link(floor) + exclusive_issues, _warnings, _stats = addon._validate_scene( + bpy.context, inspect_geometry=False + ) + require( + any( + "must belong to exactly one map group" in issue + for issue in exclusive_issues + ), + "Validation did not reject multiple group memberships", + ) + addon._move_to_group( + scene, + floor, + addon.exporter.PRESENTATION_COLLISION_COLLECTION, + ) + addon._move_to_group( + scene, + shared_leaf_card, + addon.exporter.NO_COLLISION_COLLECTION, + ) + shared_material_issues, _warnings, _stats = addon._validate_scene( + bpy.context, inspect_geometry=False + ) + require( + not shared_material_issues, + "Validation rejected one visual material used by objects with " + "different collision roles: " + "; ".join(shared_material_issues), + ) + require( + floor.data.uv_layers.get("UVMap") is not None + and floor.data.uv_layers.get("Lightmap") is not None, + "Automatic export did not create required UV layers", + ) + for layer_name in ("TEXCOORD", "UVMap", "Lightmap"): + require( + all( + float("-inf") < float(coordinate) < float("inf") + and abs(float(coordinate)) <= 1.0e6 + for item in floor.data.uv_layers[layer_name].data + for coordinate in item.uv + ), + f"Generated {layer_name} contains uninitialized UV data", + ) + generated_grass_uvs = { + tuple(round(float(value), 6) for value in item.uv) + for item in grass_planter.data.uv_layers["UVMap"].data + } + require( + len(generated_grass_uvs) > 1 + and bool(grass_planter.data.get("ow_generated_box_uv", False)), + "A mesh without source UVs still received a collapsed zero UV", + ) + require( + material.get("ow_albedo_image") == image.name, + "Principled Base Color image was not imported", + ) + require( + material.get("ow_uv_map") == "TEXCOORD", + "An explicit Blender UV Map node was not preserved", + ) + require( + tuple(material.get("ow_uv_transform", ())) + == (2.0, 3.0, 0.0, 0.25, -0.5), + "A common Blender Mapping-node UV transform was not preserved", + ) + require( + int(cutout_material.get("ow_alpha_mode", 0)) == 1 + and bool(cutout_shader.inputs["Alpha"].links), + "Auto Prepare did not preserve shader-authored transparency", + ) + require( + int(cutout_material.get("ow_audio_surface", -1)) == 55 + and int(cutout_material.get("ow_physics_surface", -1)) == 3, + "Legacy alpha repair overwrote authored Skate surface semantics", + ) + require( + abs(float(material.get("ow_roughness", 0.0)) - 0.63) < 1.0e-5, + "Principled roughness was not imported", + ) + require( + bool(material.get("ow_auto_imported", False)), + "Automatically imported material was not marked refreshable", + ) + require( + int(material.get("ow_cull_mode", 0)) == 1, + "Blender's default two-sided material policy was not preserved", + ) + normal = bpy.data.images.get( + str(material.get("ow_normal_image", "")) + ) + orm = bpy.data.images.get(str(material.get("ow_orm_image", ""))) + require( + normal is None and orm is not None, + "Auto Prepare invented a normal map or lost scalar metallic", + ) + require( + orm.packed_file is not None + and orm.use_fake_user, + "Generated metallic ORM map was not made persistent", + ) + require( + abs(scene.owned_world.day_ambient - 0.32) < 1e-6 + and abs(scene.owned_world.night_ambient - 0.11) < 1e-6, + "Local lights rewrote global ambient lighting", + ) + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated_mesh, evaluated_object = addon.exporter._mesh_for_export( + floor, depsgraph, preserve_all_data_layers=True + ) + try: + require( + len(evaluated_mesh.polygons) > len(floor.data.polygons), + "Build did not evaluate the Solidify modifier", + ) + finally: + if evaluated_object is not None: + evaluated_object.to_mesh_clear() + + # Older add-on builds could save an auto-imported material before its + # Blender image links were available, leaving an empty albedo field + # that every later export treated as authoritative. Automatic + # materials must heal from their shader graph on the next export. + material["ow_albedo_image"] = "" + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Automatic export did not refresh stale material metadata", + ) + require( + material.get("ow_albedo_image") == image.name, + "Stale auto-imported albedo metadata was not healed", + ) + + # A real UI edit transfers ownership to the author, preventing future + # automatic preparation from overwriting deliberate choices. + material.owned_world.roughness = 0.51 + require( + not bool(material.get("ow_auto_imported", True)), + "Manual material edit did not disable automatic overwrites", + ) + require( + not addon._auto_configure_material(material), + "Automatic preparation overwrote a manually edited material", + ) + require( + abs(float(material.get("ow_roughness", 0.0)) - 0.51) < 1.0e-5, + "Manual material value was not preserved", + ) + material["ow_albedo_image"] = "" + require( + addon._auto_configure_material( + material, generate_maps=True + ) + and material.get("ow_albedo_image") == image.name + and abs(float(material.get("ow_roughness", 0.0)) - 0.51) + < 1.0e-5 + and not bool(material.get("ow_auto_imported", True)), + "Auto Prepare did not fill a missing authored albedo while " + "preserving manual scalar settings", + ) + + # Older add-on builds saved generated images as transient datablocks. + # On reload Blender retained their names and metadata but restored + # black pixels. Authored materials preserve deliberate source maps, + # but add-on-owned metallic ORM maps must still be regenerated and + # packed. + generated_name = str(material["ow_orm_image"]) + previous = bpy.data.images.get(generated_name) + require(previous is not None, "Missing generated ORM setup") + bpy.data.images.remove(previous) + broken = bpy.data.images.new( + generated_name, width=4, height=4, alpha=True + ) + broken["ow_generated_map"] = "ORM" + broken["ow_generated_for"] = material.name + broken.use_fake_user = True + _stats, repair_warnings = addon._auto_prepare_scene(bpy.context) + require(not repair_warnings, "Legacy generated-map repair warned") + repaired_orm = bpy.data.images[generated_name] + repaired_orm_pixels = list(repaired_orm.pixels[:]) + require( + any( + repaired_orm_pixels[index] + or repaired_orm_pixels[index + 1] + or repaired_orm_pixels[index + 2] + for index in range(0, len(repaired_orm_pixels), 4) + ) + and repaired_orm.packed_file is not None, + "Auto Prepare did not repair a black legacy generated ORM map", + ) + require( + abs(float(material.get("ow_roughness", 0.0)) - 0.51) < 1.0e-5 + and not bool(material.get("ow_auto_imported", True)), + "Generated-map repair overwrote authored material settings", + ) + + shader.inputs["Roughness"].default_value = 0.41 + bpy.ops.object.select_all(action="DESELECT") + floor.select_set(True) + bpy.context.view_layer.objects.active = floor + require( + bpy.ops.owmaterial.apply_preset(preset="TREE") == {"FINISHED"}, + "Tree material preset failed", + ) + require( + material.get("ow_presentation_type") == "VEGETATION" + and int(material.get("ow_alpha_mode", 0)) == 1 + and not bool(material.get("ow_collision_enabled", True)), + "Tree preset did not configure optimized cutout presentation", + ) + require( + bpy.ops.owmaterial.sync_materials() == {"FINISHED"}, + "Sync Materials operator failed", + ) + require( + abs(float(material.get("ow_roughness", 0.0)) - 0.41) < 1.0e-5, + "Sync Materials did not refresh shader roughness", + ) + orm = bpy.data.images[str(material["ow_orm_image"])] + orm_pixels = [0.0] * (orm.size[0] * orm.size[1] * 4) + orm.pixels.foreach_get(orm_pixels) + require( + abs(orm_pixels[1] - 0.41) < 0.01, + "Sync Materials did not overwrite generated roughness data", + ) + + sharp_mesh = bpy.data.meshes.new("SharpRailMesh") + sharp_mesh.from_pydata( + [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)], + [(0, 1), (1, 2)], + [], + ) + sharp_mesh.update() + sharp_attribute = sharp_mesh.attributes.new( + "sharp_edge", "BOOLEAN", "EDGE" + ) + for value in sharp_attribute.data: + value.value = True + sharp_object = bpy.data.objects.new("SharpRail", sharp_mesh) + scene.collection.objects.link(sharp_object) + bpy.ops.object.select_all(action="DESELECT") + sharp_object.select_set(True) + bpy.context.view_layer.objects.active = sharp_object + require( + bpy.ops.skate_map.grinds_from_sharp_edges() == {"FINISHED"}, + "Sharp-edge grind generation failed", + ) + grind_object = bpy.context.object + require( + grind_object.type == "CURVE" + and len(grind_object.data.splines) == 1 + and len(grind_object.data.splines[0].points) == 3, + "Sharp edges were not consolidated into one three-point spline", + ) + require( + addon._object_groups(grind_object) + == [addon.exporter.GRIND_COLLECTION], + "Generated grind spline was not placed exclusively in Group 4", + ) + grind_data = grind_object.data + bpy.data.objects.remove(grind_object, do_unlink=True) + bpy.data.curves.remove(grind_data) + bpy.data.objects.remove(sharp_object, do_unlink=True) + bpy.data.meshes.remove(sharp_mesh) + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + "Automatic export failed after a manual material edit", + ) + collider_fallback = bpy.data.materials.get( + str(collider.get("ow_material", "")) + ) + require( + collider_fallback is not None + and bool( + collider_fallback.get("ow_collision_enabled", True) + ), + "Collider-only mesh did not receive a visible colliding fallback " + "material", + ) + + _, _, counts = addon.exporter._read_package_header(output) + require( + counts[-2] == 1, + "Only the real Blender Light should have been exported", + ) + + scalar_output = output.with_name( + "auto_import_workflow_scalar.skate" + ) + scalar_output.unlink(missing_ok=True) + original_numpy = addon.exporter.numpy + addon.exporter.numpy = None + try: + addon.exporter.export_scene( + scalar_output, force_rebuild=True + ) + finally: + addon.exporter.numpy = original_numpy + _, _, scalar_counts = addon.exporter._read_package_header( + scalar_output + ) + require( + scalar_counts == counts, + "Bulk and scalar geometry paths produced different counts", + ) + semantic_failures, _, _, _ = compare_packages( + scalar_output, output + ) + require( + not semantic_failures, + "Bulk and scalar geometry paths produced different decoded " + f"content: {'; '.join(semantic_failures)}", + ) + + persistence_blend = Path(tempfile.gettempdir()) / ( + "auto_import_generated_map_persistence.blend" + ) + persistence_blend.unlink(missing_ok=True) + persisted_material_name = material.name + persisted_complex_material_name = complex_material.name + require( + bpy.ops.wm.save_as_mainfile(filepath=str(persistence_blend)) + == {"FINISHED"}, + "Could not save generated-map persistence fixture", + ) + require( + bpy.ops.wm.open_mainfile( + filepath=str(persistence_blend), load_ui=False + ) + == {"FINISHED"}, + "Could not reopen generated-map persistence fixture", + ) + reloaded_material = bpy.data.materials[persisted_material_name] + reloaded_orm = bpy.data.images[ + str(reloaded_material["ow_orm_image"]) + ] + reloaded_complex = bpy.data.materials[persisted_complex_material_name] + reloaded_bake = bpy.data.images[ + str(reloaded_complex["ow_baked_base_color_image"]) + ] + pixels = list(reloaded_orm.pixels[:]) + require( + reloaded_orm.packed_file is not None + and any( + pixels[index] + or pixels[index + 1] + or pixels[index + 2] + for index in range(0, len(pixels), 4) + ), + "Generated ORM map became black after save/reopen", + ) + require( + reloaded_bake.packed_file is not None + and any( + reloaded_bake.pixels[index] + or reloaded_bake.pixels[index + 1] + or reloaded_bake.pixels[index + 2] + for index in range(0, len(reloaded_bake.pixels), 4) + ), + "Flattened Blender Base Color became black after save/reopen", + ) + reloaded_scene = bpy.context.scene + require( + bpy.ops.skate_map.quick_export() == {"FINISHED"}, + reloaded_scene.owned_world.validation_details + or "Export failed after generated-map save/reopen", + ) + print( + "AUTO_IMPORT_WORKFLOW_OK", + output, + output.stat().st_size, + reloaded_scene.owned_world.last_status, + ) + finally: + addon.unregister() + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/vendor/university/tools/blender_owned_map/test_auto_map_grinds.py b/tools/vendor/university/tools/blender_owned_map/test_auto_map_grinds.py new file mode 100644 index 00000000..4b878276 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/test_auto_map_grinds.py @@ -0,0 +1,169 @@ +"""Headless regression tests for automatic grind source and density limits.""" + +from pathlib import Path +import sys + +import bpy +from mathutils import Vector + + +SCRIPT_ROOT = ( + Path(__file__).resolve().parent + / "agent_workflow" + / "sk8-auto-map" + / "scripts" +) +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +from generate_grinds import ( # noqa: E402 + GrindSettings, + _candidate_segments, + _limit_chain_density, + generate_grinds, +) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def make_plane( + collection: bpy.types.Collection, name: str, x: float +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata( + [ + (x - 1.0, -1.0, 0.0), + (x + 1.0, -1.0, 0.0), + (x + 1.0, 1.0, 0.0), + (x - 1.0, 1.0, 0.0), + ], + [], + [(0, 1, 2, 3)], + ) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + collection.objects.link(obj) + return obj + + +def main() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + + collision = bpy.data.collections.new( + "OW_GROUP_1_PRESENTATION_COLLISION" + ) + visual_only = bpy.data.collections.new("OW_GROUP_3_NO_COLLISION") + legacy_visual = bpy.data.collections.new("OW_VISUAL") + legacy_collision = bpy.data.collections.new("OW_COLLISION") + bpy.context.scene.collection.children.link(collision) + bpy.context.scene.collection.children.link(visual_only) + bpy.context.scene.collection.children.link(legacy_visual) + bpy.context.scene.collection.children.link(legacy_collision) + collision_plane = make_plane(collision, "CollisionPlane", 0.0) + make_plane(visual_only, "VisualPlane", 10.0) + legacy_owner = make_plane(legacy_visual, "LegacyVisual", 20.0) + legacy_collider = make_plane( + legacy_collision, "LegacyCollider", 20.0 + ) + legacy_collider["ow_map_object_owner"] = legacy_owner.name + + settings = GrindSettings( + minimum_segment_length=0.1, + maximum_slope_degrees=90.0, + density_cell_size=0.0, + ) + candidates = _candidate_segments(settings) + require( + len(candidates) == 8, + f"Expected eight current/legacy boundary edges: {candidates}", + ) + require( + {segment.source for segment in candidates} + == {"CollisionPlane", "LegacyCollider"}, + "Visual-only geometry was used or legacy collision was ignored", + ) + generated = generate_grinds(settings) + generated_by_source = { + str(obj["sk8_source_object"]): obj + for obj in bpy.data.objects + if bool(obj.get("sk8_auto_generated_grinds", False)) + } + require( + generated["splines"] == 2 + and generated_by_source["CollisionPlane"].parent + is collision_plane + and generated_by_source["LegacyCollider"].parent is legacy_owner, + "generated grinds did not retain editable visual ownership", + ) + + manual_collection = bpy.data.collections.new("OW_GRIND") + bpy.context.scene.collection.children.link(manual_collection) + manual_curve = bpy.data.curves.new("ManualLegacyGrind", "CURVE") + manual_spline = manual_curve.splines.new("POLY") + manual_spline.points.add(1) + manual_spline.points[0].co = (-1.0, 0.0, 0.0, 1.0) + manual_spline.points[1].co = (1.0, 0.0, 0.0, 1.0) + manual_grind = bpy.data.objects.new( + "ManualLegacyGrind", manual_curve + ) + manual_collection.objects.link(manual_grind) + manual_grind.parent = legacy_owner + candidates_with_manual = _candidate_segments(settings) + require( + {segment.source for segment in candidates_with_manual} + == {"CollisionPlane"}, + "automatic grinds duplicated a manually authored owner", + ) + legacy_collider["sk8_auto_grind_exclude"] = True + manual_collection.objects.unlink(manual_grind) + bpy.data.objects.remove(manual_grind) + candidates_with_exclusion = _candidate_segments(settings) + require( + {segment.source for segment in candidates_with_exclusion} + == {"CollisionPlane"}, + "explicit manual-grind exclusion was ignored", + ) + + chains = { + "DenseRail": [ + [Vector((0.0, 0.01 * index, 0.0)), Vector((length, 0.01 * index, 0.0))] + for index, length in enumerate((1.0, 2.0, 3.0, 4.0)) + ] + } + limited, rejected = _limit_chain_density( + chains, + GrindSettings( + density_cell_size=10.0, + maximum_splines_per_cell=2, + maximum_splines_per_source=128, + ), + ) + lengths = sorted( + round((chain[1] - chain[0]).length, 4) + for chain in limited["DenseRail"] + ) + require(lengths == [3.0, 4.0], f"Density limit kept {lengths}") + require(rejected == 2, f"Density limit rejected {rejected} chains") + print( + "AUTO_MAP_GRINDS_OK", + len(candidates), + generated["splines"], + lengths, + rejected, + ) + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/vendor/university/tools/blender_owned_map/test_auto_map_grouped_plan.py b/tools/vendor/university/tools/blender_owned_map/test_auto_map_grouped_plan.py new file mode 100644 index 00000000..6b2889c8 --- /dev/null +++ b/tools/vendor/university/tools/blender_owned_map/test_auto_map_grouped_plan.py @@ -0,0 +1,224 @@ +"""Headless regression test for grouped agent inventory and map plans.""" + +from pathlib import Path +import sys + +import bpy + + +TOOL_ROOT = Path(__file__).resolve().parent +SCRIPT_ROOT = TOOL_ROOT / "agent_workflow" / "sk8-auto-map" / "scripts" +for path in (TOOL_ROOT, SCRIPT_ROOT): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import owned_world_material_addon as addon # noqa: E402 +from apply_map_plan import apply_plan # noqa: E402 +from inventory_scene import build_inventory # noqa: E402 + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def make_plane( + collection: bpy.types.Collection, + name: str, + x_offset: float, + material: bpy.types.Material, +) -> bpy.types.Object: + mesh = bpy.data.meshes.new(f"{name}Mesh") + mesh.from_pydata( + [ + (-1.0, -1.0, 0.0), + (1.0, -1.0, 0.0), + (1.0, 1.0, 0.0), + (-1.0, 1.0, 0.0), + ], + [], + [(0, 1, 2, 3)], + ) + mesh.materials.append(material) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + obj.location.x = x_offset + collection.objects.link(obj) + return obj + + +def material_plan( + name: str, + collision: bool, + emissive_strength: float | None = None, +) -> dict: + result = { + "material": name, + "audio_surface": 4, + "physics_surface": 2, + "surface_pattern": 0, + "collision_enabled": collision, + } + if emissive_strength is not None: + result["emissive_strength"] = emissive_strength + return result + + +def main() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + for material in list(bpy.data.materials): + bpy.data.materials.remove(material) + + source = bpy.data.collections.new("Imported_Source") + bpy.context.scene.collection.children.link(source) + fixture_material = bpy.data.materials.new("FixtureMetal") + plaza_material = bpy.data.materials.new("PlazaConcrete") + fixture = make_plane(source, "Fixture", 0.0, fixture_material) + fixture_duplicate = make_plane( + source, "Fixture.001", 5.0, fixture_material + ) + plaza = make_plane(source, "Plaza", 12.0, plaza_material) + + inventory, members = build_inventory() + require(inventory["version"] == 2, "Grouped inventory version changed") + require( + inventory["summary"]["objects"] == 3 + and inventory["summary"]["mesh_groups"] == 2 + and inventory["summary"]["agent_object_records"] == 2 + and inventory["summary"]["collapsed_duplicate_instances"] == 1, + f"Unexpected grouping summary: {inventory['summary']}", + ) + fixture_group = next( + group + for group in inventory["mesh_groups"] + if group["name_family"] == "Fixture" + ) + plaza_group = next( + group + for group in inventory["mesh_groups"] + if group["name_family"] == "Plaza" + ) + require( + fixture_group["instance_count"] == 2 + and fixture_group["representative"]["name"] == fixture.name, + f"Fixture instances were not collapsed: {fixture_group}", + ) + require( + "objects" not in inventory["materials"][0], + "Material inventory still duplicates object-name lists", + ) + fixture_usage = next( + material["usage"] + for material in inventory["materials"] + if material["name"] == fixture_material.name + ) + require( + fixture_usage["object_count"] == 2 + and fixture_usage["group_count"] == 1, + f"Material usage was not collapsed by group: {fixture_usage}", + ) + require( + members["mesh_groups"][fixture_group["group"]] + == [fixture.name, fixture_duplicate.name], + "Script-only membership sidecar lost exact object names", + ) + + incomplete_plan = { + "version": 3, + "object_group_roles": [ + {"group": fixture_group["group"], "role": "VISUAL_ONLY"}, + ], + "grinds": {"enabled": False}, + } + try: + apply_plan(incomplete_plan) + except ValueError as exc: + require( + "must classify every mesh group" in str(exc), + f"Unexpected incomplete grouped-plan error: {exc}", + ) + else: + raise RuntimeError("Version 3 accepted an incomplete mesh group plan") + + plan = { + "version": 3, + "map_name": "Grouped Plan Test", + "materials": [ + material_plan( + fixture_material.name, + True, + emissive_strength=1.0, + ), + material_plan(plaza_material.name, True), + ], + "object_group_roles": [ + {"group": fixture_group["group"], "role": "VISUAL_ONLY"}, + {"group": plaza_group["group"], "role": "DEFAULT"}, + ], + "object_roles": [ + {"object": fixture_duplicate.name, "role": "DEFAULT"}, + ], + "lights": [ + { + "source_group": fixture_group["group"], + "type": "POINT", + "energy": 100.0, + "range": 4.0, + "offset": [0.0, 0.0, 1.0], + } + ], + "spawn": { + "location": [12.0, 0.0, 0.15], + "heading_degrees": 0.0, + }, + "grinds": {"enabled": False}, + } + result = apply_plan(plan) + require( + addon._object_groups(fixture) + == [addon.exporter.NO_COLLISION_COLLECTION], + "Grouped VISUAL_ONLY role was not expanded", + ) + require( + addon._object_groups(fixture_duplicate) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + "Exact role override did not replace its grouped role", + ) + require( + addon._object_groups(plaza) + == [addon.exporter.PRESENTATION_COLLISION_COLLECTION], + "Grouped DEFAULT role was not expanded", + ) + require( + result["object_groups"]["mesh_groups"] == 2 + and result["object_groups"]["mesh_object_expansions"] == 3 + and result["object_groups"]["exact_object_overrides"] == 1, + f"Unexpected grouped apply result: {result}", + ) + require(result["lights"] == 2, "Grouped fixture lights were not expanded") + require( + abs(float(fixture_material.get("ow_emissive", 0.0)) - 1.0) + < 1.0e-6, + "Visible fixture material was not made emissive", + ) + require(result["spawn"], "Explicit visual spawn was not applied") + require( + tuple(round(float(value), 4) for value in bpy.data.objects["OW_SPAWN"].location) + == (12.0, 0.0, 0.15), + "Explicit visual spawn location changed during application", + ) + print("AUTO_MAP_GROUPED_PLAN_OK", result) + addon.unregister() + + +if __name__ == "__main__": + try: + main() + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/fix_skate2_scene.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/fix_skate2_scene.py new file mode 100644 index 00000000..85a2ff7f --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/fix_skate2_scene.py @@ -0,0 +1,205 @@ +"""Repair Skate 2's mixed-coordinate grind rails in an existing .blend.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import bpy + + +BLENDER_DIRECTORY = Path(__file__).resolve().parent +TOOLS_DIRECTORY = BLENDER_DIRECTORY.parent / "tools" +sys.path.insert(0, str(BLENDER_DIRECTORY)) +sys.path.insert(0, str(TOOLS_DIRECTORY)) + +from import_hawaiian_dream import _retail_grind_controls # noqa: E402 +from retail_grind_splines import ( # noqa: E402 + classify_grind_coordinate_frame, + grind_cell_translation, + translate_native_segment_payload, +) + + +SIGN_35_ASSET_ID = "0xd4538d2e1da5434f" +EXPECTED_RAIL_COUNT = 36_058 + + +def _payloads(obj: bpy.types.Object) -> list[str]: + segment_count = int(obj["skate3_retail_grind_segment_count"]) + payload_hex = str(obj["skate3_retail_grind_segment_payload"]) + segment_hex_size = 120 * 2 + if len(payload_hex) != segment_count * segment_hex_size: + raise ValueError(f"{obj.name} has an invalid native payload size") + return [ + payload_hex[offset : offset + segment_hex_size] + for offset in range(0, len(payload_hex), segment_hex_size) + ] + + +def _set_curve_from_payloads( + obj: bpy.types.Object, + payloads: list[str], +) -> None: + if obj.type != "CURVE" or len(obj.data.splines) != 1: + raise ValueError(f"{obj.name} is not a single-spline curve") + spline = obj.data.splines[0] + if spline.type != "BEZIER": + raise ValueError(f"{obj.name} is not a Bezier curve") + expected_segments = len(payloads) + actual_segments = ( + len(spline.bezier_points) + if spline.use_cyclic_u + else len(spline.bezier_points) - 1 + ) + if actual_segments != expected_segments: + raise ValueError( + f"{obj.name} has {actual_segments} curve segments but " + f"{expected_segments} native segments" + ) + + point_count = len(spline.bezier_points) + for point in spline.bezier_points: + point.handle_left_type = "FREE" + point.handle_right_type = "FREE" + for segment_index, payload in enumerate(payloads): + point_0, point_1, point_2, point_3 = _retail_grind_controls(payload) + current = spline.bezier_points[segment_index] + following_index = (segment_index + 1) % point_count + following = spline.bezier_points[following_index] + current.co = point_0 + current.handle_right = point_1 + following.handle_left = point_2 + if not spline.use_cyclic_u or following_index != 0: + following.co = point_3 + if not spline.use_cyclic_u: + spline.bezier_points[0].handle_left = spline.bezier_points[0].co + spline.bezier_points[-1].handle_right = ( + spline.bezier_points[-1].co + ) + + +def _repair_grinds() -> dict[str, int]: + rails = [ + obj + for obj in bpy.data.objects + if bool(obj.get("skate3_retail_grind", False)) + ] + if len(rails) != EXPECTED_RAIL_COUNT: + raise RuntimeError( + f"expected {EXPECTED_RAIL_COUNT} Skate 2 rails, found {len(rails)}" + ) + + counts = {"cell_local": 0, "world_space": 0, "ambiguous": 0} + for rail_index, obj in enumerate(rails, 1): + source_frame = obj.get( + "skate3_retail_grind_source_coordinate_frame" + ) + if source_frame is not None: + raise RuntimeError( + f"{obj.name} is already marked as coordinate-repaired" + ) + source_payloads = _payloads(obj) + source_frame = classify_grind_coordinate_frame( + str(obj["skate3_stream_file"]), + source_payloads, + margin=40.0, + ) + counts[source_frame] += 1 + output_payloads = source_payloads + translation = None + if source_frame == "cell_local": + translation = grind_cell_translation( + str(obj["skate3_stream_file"]) + ) + if translation is None: + raise RuntimeError( + f"{obj.name} is cell-local without a cell translation" + ) + output_payloads = [ + translate_native_segment_payload(payload, translation) + for payload in source_payloads + ] + + obj.location = (0.0, 0.0, 0.0) + obj.rotation_euler = (0.0, 0.0, 0.0) + obj.scale = (1.0, 1.0, 1.0) + _set_curve_from_payloads(obj, output_payloads) + obj["skate3_retail_grind_segment_payload"] = "".join( + output_payloads + ) + obj["skate3_retail_grind_source_coordinate_frame"] = source_frame + if translation is not None: + obj["skate3_retail_grind_cell_translation"] = translation + if rail_index % 2_000 == 0: + print( + f"Repaired {rail_index}/{len(rails)} grind rails", + flush=True, + ) + return counts + + +def _remove_unplaced_sign() -> int: + objects = [ + obj + for obj in bpy.data.objects + if str(obj.get("skate3_asset_id", "")).lower() == SIGN_35_ASSET_ID + and not bool(obj.get("skate3_retail_grind", False)) + ] + for obj in objects: + bpy.data.objects.remove(obj, do_unlink=True) + for collection in list(bpy.data.collections): + if collection.name.upper() == "MODEL_D4538D2E1DA5434F": + bpy.data.collections.remove(collection) + return len(objects) + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 1: + raise SystemExit( + "usage: blender INPUT.blend --background --python " + "fix_skate2_scene.py -- OUTPUT.blend" + ) + output = Path(arguments[0]).resolve() + scene = bpy.context.scene + if "Skate 2" not in str(scene.get("skate3_map_name", scene.name)): + raise RuntimeError("the open scene is not the Skate 2 BAM import") + + counts = _repair_grinds() + removed_sign_objects = _remove_unplaced_sign() + if removed_sign_objects != 1: + raise RuntimeError( + f"expected one Sign_35 object, removed {removed_sign_objects}" + ) + + scene["skate3_grind_status"] = ( + f"{EXPECTED_RAIL_COUNT} exact retail grind rails; " + f"{counts['cell_local']} cell-local rails baked into world space; " + f"{counts['ambiguous']} central boundary rails preserved unchanged" + ) + scene["skate3_excluded_static_model_status"] = ( + "1 unplaced Sign_35 prop mesh excluded by Skate 2 import policy" + ) + scene["skate3_skate2_coordinate_repair"] = json.dumps( + counts, + sort_keys=True, + ) + bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False) + print( + json.dumps( + { + "output": str(output), + "grind_coordinate_frames": counts, + "removed_sign_objects": removed_sign_objects, + }, + indent=2, + ), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/import_hawaiian_dream.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_hawaiian_dream.py new file mode 100644 index 00000000..65e96ea0 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_hawaiian_dream.py @@ -0,0 +1,1071 @@ +"""Build and save Danny Way's Hawaiian Dream as a textured Blender scene.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import struct +import sys + +import bpy +import numpy + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +from retail_collision_mesh import decode_rx2_clustered_meshes +from retail_grind_splines import ( + classify_grind_coordinate_frame, + grind_cell_translation, + translate_native_segment_payload, +) + + +RETAIL_NORMAL_ATTRIBUTE = "skate3_retail_normal" +RETAIL_TANGENT_ATTRIBUTE = "skate3_retail_tangent" +RETAIL_HANDEDNESS_ATTRIBUTE = "skate3_retail_tangent_handedness" + + +def _safe_name(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") + return (cleaned or fallback)[:120] + + +def _runtime_to_blender(values: numpy.ndarray) -> numpy.ndarray: + converted = values[:, [0, 2, 1]].copy() + converted[:, 1] *= -1.0 + return converted + + +def _set_point_vector_attribute( + mesh: bpy.types.Mesh, + name: str, + values: numpy.ndarray, +) -> None: + if values.shape != (len(mesh.vertices), 3): + raise ValueError( + f"{mesh.name} attribute {name!r} has shape {values.shape}, " + f"expected ({len(mesh.vertices)}, 3)" + ) + attribute = mesh.attributes.new( + name=name, + type="FLOAT_VECTOR", + domain="POINT", + ) + attribute.data.foreach_set( + "vector", + numpy.ascontiguousarray(values, dtype=numpy.float32).ravel(), + ) + + +def _set_point_float_attribute( + mesh: bpy.types.Mesh, + name: str, + values: numpy.ndarray, +) -> None: + if values.shape != (len(mesh.vertices),): + raise ValueError( + f"{mesh.name} attribute {name!r} has shape {values.shape}, " + f"expected ({len(mesh.vertices)},)" + ) + attribute = mesh.attributes.new( + name=name, + type="FLOAT", + domain="POINT", + ) + attribute.data.foreach_set( + "value", + numpy.ascontiguousarray(values, dtype=numpy.float32), + ) + + +def _clear_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + bpy.data.collections.remove(collection) + + +def _new_child_collection( + parent: bpy.types.Collection | bpy.types.Scene, + name: str, +) -> bpy.types.Collection: + collection = bpy.data.collections.new(name) + if isinstance(parent, bpy.types.Scene): + parent.collection.children.link(collection) + else: + parent.children.link(collection) + return collection + + +def _load_images( + manifest: dict[str, object], + cache_root: Path, +) -> dict[str, bpy.types.Image]: + images: dict[str, bpy.types.Image] = {} + for texture_id, entry in manifest["textures"].items(): + image_path = cache_root / entry["png"] + # Retail texture IDs are binding identities, even when two IDs resolve + # to the same decoded PNG. Reusing a Blender image here allows a later + # alias to rename the shared datablock and makes the earlier ID + # impossible for the exporter to resolve. + image = bpy.data.images.load(str(image_path), check_existing=False) + image.name = texture_id + image["skate3_retail_texture_id"] = texture_id + # Most secondary retail roles are not connected to Blender preview + # nodes. Keep them in the saved .blend so the SKATE exporter can bind + # every original channel rather than losing zero-user datablocks. + image.use_fake_user = True + images[texture_id] = image + return images + + +def _new_material( + texture_id: str, + image: bpy.types.Image | None, + alpha_mode: int, + *, + lightmap_texture_id: str = "", + lightmap_image: bpy.types.Image | None = None, + normal_texture_id: str = "", + normal_image: bpy.types.Image | None = None, + shader_name: str = "", + retail_material_guid: str = "", + retail_material_handle: str = "", + retail_material_group_index: int = -1, + retail_texture_ids: dict[str, str] | None = None, + retail_parameters: dict[str, list[str]] | None = None, + retail_source: dict[str, object] | None = None, + fallback: bool = False, +) -> bpy.types.Material: + alpha_suffix = ("OPAQUE", "MASK", "BLEND")[alpha_mode] + fallback_suffix = "_FALLBACK" if fallback else "" + identity = "|".join( + ( + texture_id, + lightmap_texture_id, + normal_texture_id, + str(alpha_mode), + ) + ) + identity_digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12] + material = bpy.data.materials.new( + f"MAT_{texture_id}_{identity_digest}_" + f"{alpha_suffix}{fallback_suffix}" + ) + material.use_nodes = True + material.use_backface_culling = True + nodes = material.node_tree.nodes + links = material.node_tree.links + principled = nodes.get("Principled BSDF") + base_color_output = None + if image is not None: + texture = nodes.new("ShaderNodeTexImage") + texture.name = f"TEX_{texture_id}" + texture.label = texture_id + texture.image = image + texture.interpolation = "Linear" + base_color_output = texture.outputs["Color"] + links.new(texture.outputs["Alpha"], principled.inputs["Alpha"]) + else: + principled.inputs["Base Color"].default_value = ( + 1.0, + 1.0, + 1.0, + 1.0, + ) + if lightmap_texture_id: + if lightmap_image is None: + raise ValueError( + f"lightmap texture {lightmap_texture_id} is missing " + "from the cache" + ) + lightmap_image.colorspace_settings.name = "Non-Color" + lightmap_image["ow_lightmap_encoding"] = ( + "skate3_retail_sqrt_linear_over_4" + ) + lightmap_texture = nodes.new("ShaderNodeTexImage") + lightmap_texture.name = f"LIGHTMAP_{lightmap_texture_id}" + lightmap_texture.label = lightmap_texture_id + lightmap_texture.image = lightmap_image + lightmap_texture.interpolation = "Linear" + lightmap_texture.extension = "EXTEND" + lightmap_uv = nodes.new("ShaderNodeUVMap") + lightmap_uv.name = "RETAIL_LIGHTMAP_UV" + lightmap_uv.uv_map = "Lightmap" + links.new(lightmap_uv.outputs["UV"], lightmap_texture.inputs["Vector"]) + lightmap_square = nodes.new("ShaderNodeVectorMath") + lightmap_square.name = "RETAIL_LIGHTMAP_SQUARE" + lightmap_square.operation = "MULTIPLY" + links.new( + lightmap_texture.outputs["Color"], + lightmap_square.inputs[0], + ) + links.new( + lightmap_texture.outputs["Color"], + lightmap_square.inputs[1], + ) + lightmap_scale = nodes.new("ShaderNodeVectorMath") + lightmap_scale.name = "RETAIL_LIGHTMAP_SCALE" + lightmap_scale.operation = "SCALE" + lightmap_scale.inputs["Scale"].default_value = 4.0 + links.new(lightmap_square.outputs["Vector"], lightmap_scale.inputs[0]) + if base_color_output is not None: + baked_color = nodes.new("ShaderNodeVectorMath") + baked_color.name = "RETAIL_BAKED_COLOR" + baked_color.operation = "MULTIPLY" + links.new(base_color_output, baked_color.inputs[0]) + links.new(lightmap_scale.outputs["Vector"], baked_color.inputs[1]) + base_color_output = baked_color.outputs["Vector"] + if base_color_output is not None: + links.new(base_color_output, principled.inputs["Base Color"]) + if normal_texture_id: + if normal_image is None: + raise ValueError( + f"normal texture {normal_texture_id} is missing from the cache" + ) + normal_image.colorspace_settings.name = "Non-Color" + normal_texture = nodes.new("ShaderNodeTexImage") + normal_texture.name = f"NORMAL_{normal_texture_id}" + normal_texture.label = normal_texture_id + normal_texture.image = normal_image + normal_texture.interpolation = "Linear" + normal_map = nodes.new("ShaderNodeNormalMap") + normal_map.name = f"NORMAL_MAP_{normal_texture_id}" + links.new(normal_texture.outputs["Color"], normal_map.inputs["Color"]) + links.new(normal_map.outputs["Normal"], principled.inputs["Normal"]) + principled.inputs["Roughness"].default_value = 0.68 + principled.inputs["Metallic"].default_value = 0.0 + if alpha_mode != 0: + try: + material.surface_render_method = "DITHERED" + except Exception: + try: + material.blend_method = "HASHED" + except Exception: + pass + material["skate3_texture_id"] = texture_id + material["skate3_lightmap_texture_id"] = lightmap_texture_id + material["skate3_normal_texture_id"] = normal_texture_id + material["skate3_alpha_mode"] = alpha_mode + material["skate3_alpha_cutoff"] = 0.5 + material["skate3_shader_name"] = shader_name + material["skate3_retail_material_guid"] = retail_material_guid + material["skate3_retail_material_handle"] = retail_material_handle + material["skate3_retail_material_group_index"] = ( + retail_material_group_index + ) + material["skate3_retail_texture_ids"] = json.dumps( + retail_texture_ids or {}, + sort_keys=True, + separators=(",", ":"), + ) + material["skate3_retail_parameters"] = json.dumps( + retail_parameters or {}, + sort_keys=True, + separators=(",", ":"), + ) + material["skate3_retail_source"] = json.dumps( + retail_source or {}, + sort_keys=True, + separators=(",", ":"), + ) + material["ow_normal_image"] = ( + normal_image.name if normal_image is not None else "" + ) + material["ow_lightmap_image"] = ( + lightmap_image.name if lightmap_image is not None else "" + ) + material["ow_lightmap_encoding"] = ( + "skate3_retail_sqrt_linear_over_4" + if lightmap_image is not None + else "" + ) + material["ow_baked_strength"] = 1.0 if lightmap_image is not None else 0.0 + if fallback: + material["skate3_fallback_reason"] = ( + "Package references a shared texture that is not embedded." + ) + return material + + +def _runtime_point_to_blender( + point: tuple[float, float, float], +) -> tuple[float, float, float]: + return point[0], -point[2], point[1] + + +def _retail_grind_controls( + payload_hex: str, +) -> tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], +]: + payload = bytes.fromhex(payload_hex) + if len(payload) != 120: + raise ValueError("retail grind segment payload must contain 120 bytes") + values = struct.unpack(">30f", payload) + coefficient_a = values[0:3] + coefficient_b = values[4:7] + coefficient_c = values[8:11] + coefficient_d = values[12:15] + point_0 = coefficient_d + point_1 = tuple( + coefficient_d[axis] + coefficient_c[axis] / 3.0 + for axis in range(3) + ) + point_2 = tuple( + coefficient_d[axis] + + (2.0 * coefficient_c[axis] + coefficient_b[axis]) / 3.0 + for axis in range(3) + ) + point_3 = tuple( + coefficient_d[axis] + + coefficient_c[axis] + + coefficient_b[axis] + + coefficient_a[axis] + for axis in range(3) + ) + return tuple( + _runtime_point_to_blender(point) + for point in (point_0, point_1, point_2, point_3) + ) + + +def _import_retail_grinds( + manifest: dict[str, object], + collection: bpy.types.Collection, +) -> tuple[int, int, int, int]: + rail_count = 0 + segment_count = 0 + cell_local_count = 0 + ambiguous_count = 0 + coordinate_policy = manifest.get("grind_coordinate_policy", {}) + default_coordinate_mode = ( + "mixed_cell_local" + if manifest.get("format") == "skate2-bam-cache-v1" + else "world_space" + ) + coordinate_mode = str( + coordinate_policy.get("mode", default_coordinate_mode) + ) + classification_margin = float( + coordinate_policy.get("classification_margin", 40.0) + ) + for entry in manifest.get("grind_splines", []): + source_payloads = entry["native_segment_payloads"] + expected_segments = int(entry["segment_count"]) + if ( + len(source_payloads) != expected_segments + or expected_segments == 0 + ): + raise ValueError("retail grind manifest has invalid segment count") + source_coordinate_frame = "world_space" + payloads = source_payloads + translation = None + if coordinate_mode == "mixed_cell_local": + source_coordinate_frame = classify_grind_coordinate_frame( + entry["stream_file"], + source_payloads, + margin=classification_margin, + ) + if source_coordinate_frame == "cell_local": + translation = grind_cell_translation(entry["stream_file"]) + if translation is None: + raise ValueError( + "cell-local grind has no stream-cell translation" + ) + payloads = [ + translate_native_segment_payload(payload, translation) + for payload in source_payloads + ] + cell_local_count += 1 + elif source_coordinate_frame == "ambiguous": + ambiguous_count += 1 + elif coordinate_mode != "world_space": + raise ValueError( + f"unsupported grind coordinate mode {coordinate_mode!r}" + ) + controls = [_retail_grind_controls(payload) for payload in payloads] + closed = bool(entry["closed"]) + point_count = expected_segments if closed else expected_segments + 1 + curve_name = ( + f"GRIND_{entry['asset_id'][2:]}_" + f"{int(entry['rail_index']):04d}" + ) + curve_data = bpy.data.curves.new(curve_name, type="CURVE") + curve_data.dimensions = "3D" + curve_data.resolution_u = 8 + curve_data.bevel_depth = 0.015 + curve_data.bevel_resolution = 0 + spline = curve_data.splines.new("BEZIER") + spline.bezier_points.add(point_count - 1) + spline.use_cyclic_u = closed + for point in spline.bezier_points: + point.handle_left_type = "FREE" + point.handle_right_type = "FREE" + + for segment_index, segment_controls in enumerate(controls): + point_0, point_1, point_2, point_3 = segment_controls + current_index = segment_index + next_index = (segment_index + 1) % point_count + current = spline.bezier_points[current_index] + following = spline.bezier_points[next_index] + current.co = point_0 + current.handle_right = point_1 + following.handle_left = point_2 + if not closed or next_index != 0: + following.co = point_3 + if not closed: + spline.bezier_points[0].handle_left = ( + spline.bezier_points[0].co + ) + spline.bezier_points[-1].handle_right = ( + spline.bezier_points[-1].co + ) + + obj = bpy.data.objects.new(curve_name, curve_data) + collection.objects.link(obj) + obj.color = (1.0, 0.12, 0.02, 1.0) + obj["skate3_retail_grind"] = True + obj["skate3_asset_id"] = entry["asset_id"] + obj["skate3_stream_file"] = entry["stream_file"] + obj["skate3_retail_grind_rail_index"] = int(entry["rail_index"]) + obj["skate3_retail_grind_spline_id"] = entry["spline_id"] + obj["skate3_retail_grind_type_signature"] = entry["type_signature"] + obj["skate3_retail_grind_flags"] = int(entry["flags"]) + obj["skate3_retail_grind_trailing_word"] = int( + entry["trailing_word"] + ) + obj["skate3_retail_grind_segment_count"] = expected_segments + obj["skate3_retail_grind_segment_payload"] = "".join(payloads) + obj["skate3_retail_grind_source_coordinate_frame"] = ( + source_coordinate_frame + ) + if translation is not None: + obj["skate3_retail_grind_cell_translation"] = translation + rail_count += 1 + segment_count += expected_segments + return rail_count, segment_count, cell_local_count, ambiguous_count + + +def _retail_collision_material(surface: int) -> bpy.types.Material: + name = f"MAT_RETAIL_COLLISION_{surface:04X}" + material = bpy.data.materials.get(name) + if material is None: + material = bpy.data.materials.new(name) + material.diffuse_color = ( + ((surface * 37) & 0xFF) / 255.0, + ((surface * 67) & 0xFF) / 255.0, + ((surface * 97) & 0xFF) / 255.0, + 1.0, + ) + material["skate3_retail_surface_id"] = f"0x{surface:04X}" + material["ow_flags"] = 1 + material["ow_friction"] = 0.82 + material["ow_restitution"] = 0.0 + material["ow_display_color"] = tuple(material.diffuse_color[:3]) + material["ow_roughness"] = 0.68 + material["ow_emissive"] = 0.0 + material["ow_baked_strength"] = 0.0 + material["ow_albedo_image"] = "" + material["ow_lightmap_image"] = "" + material["ow_normal_image"] = "" + material["ow_orm_image"] = "" + material["ow_emissive_image"] = "" + material["ow_alpha_mode"] = 0 + material["ow_alpha_cutoff"] = 0.5 + material["ow_audio_surface"] = surface & 0x7F + material["ow_physics_surface"] = (surface >> 7) & 0x1F + material["ow_surface_pattern"] = (surface >> 12) & 0x0F + material["ow_collision_enabled"] = True + return material + + +def _import_retail_collision( + manifest: dict[str, object], + cache_root: Path, + collection: bpy.types.Collection, +) -> tuple[int, int, int]: + by_surface: dict[ + int, + tuple[ + list[tuple[float, float, float]], + list[tuple[int, int, int]], + dict[tuple[float, float, float], int], + list[tuple[int, int, int]], + ], + ] = {} + mesh_count = 0 + source_triangle_count = 0 + for entry in manifest["simulation_assets"]: + if not entry.get("collision_meshes"): + continue + path = cache_root / Path(str(entry["rx2"]).replace("\\", "/")) + meshes = decode_rx2_clustered_meshes(path.read_bytes()) + if len(meshes) != len(entry["collision_meshes"]): + raise ValueError( + f"{entry['asset_id']} retail collision section count changed" + ) + for mesh in meshes: + mesh_count += 1 + source_triangle_count += len(mesh.triangles) + for triangle in mesh.triangles: + vertices, faces, indices, surface_edge_codes = ( + by_surface.setdefault( + triangle.surface, + ([], [], {}, []), + ) + ) + face = [] + for runtime_point in (triangle.a, triangle.b, triangle.c): + point = _runtime_point_to_blender(runtime_point) + index = indices.get(point) + if index is None: + index = len(vertices) + indices[point] = index + vertices.append(point) + face.append(index) + faces.append(tuple(face)) + if triangle.edge_codes is None: + raise ValueError( + f"{entry['asset_id']} retail collision triangle " + "omits native edge codes" + ) + surface_edge_codes.append(triangle.edge_codes) + + imported_triangles = 0 + for surface, ( + vertices, + faces, + _indices, + edge_codes, + ) in sorted(by_surface.items()): + name = f"RETAIL_COLLISION_{surface:04X}" + mesh_data = bpy.data.meshes.new(f"{name}_MESH") + mesh_data.from_pydata(vertices, [], faces) + mesh_data.update(calc_edges=False) + for corner in range(3): + attribute = mesh_data.attributes.new( + f"skate3_retail_edge_code_{corner}", + type="INT", + domain="FACE", + ) + attribute.data.foreach_set( + "value", + [codes[corner] for codes in edge_codes], + ) + material = _retail_collision_material(surface) + mesh_data.materials.append(material) + obj = bpy.data.objects.new(name, mesh_data) + collection.objects.link(obj) + obj["skate3_retail_collision"] = True + obj["skate3_retail_surface_id"] = f"0x{surface:04X}" + obj["skate3_retail_triangle_count"] = len(faces) + # Retail ClusteredMesh commonly stores reverse-wound partner faces. + # They make a patch intentionally collidable from both sides and must + # not be collapsed by the generic collision cleanup. + obj["ow_preserve_opposite_wound_collision"] = True + obj["ow_preserve_retail_edge_codes"] = True + obj["ow_material"] = material.name + imported_triangles += len(faces) + + if imported_triangles != source_triangle_count: + raise ValueError( + "retail collision import changed the decoded triangle count" + ) + return mesh_count, len(by_surface), imported_triangles + + +def build_scene(manifest_path: Path) -> dict[str, int]: + manifest_text = manifest_path.read_text(encoding="utf-8") + manifest = json.loads(manifest_text) + cache_root = manifest_path.parent + _clear_scene() + + scene = bpy.context.scene + scene_name = manifest["map_name"] + scene.name = scene_name + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + manifest_block = bpy.data.texts.get("SKATE3_RETAIL_MANIFEST") + if manifest_block is None: + manifest_block = bpy.data.texts.new("SKATE3_RETAIL_MANIFEST") + else: + manifest_block.clear() + manifest_block.write(manifest_text) + + root = _new_child_collection(scene, scene_name) + presentation = _new_child_collection(root, "Presentation") + retail_grinds = _new_child_collection(root, "Retail_Grinds") + collision = _new_child_collection(root, "Collision_RAW") + collision.hide_viewport = True + collision.hide_render = True + metadata = _new_child_collection(root, "Metadata") + images = _load_images(manifest, cache_root) + materials: dict[tuple[object, ...], bpy.types.Material] = {} + excluded_normal_texture_ids = { + str(texture_id).lower() + for texture_id in manifest.get("normal_texture_policy", {}).get( + "excluded_texture_ids", + [], + ) + } + + object_count = 0 + vertex_count = 0 + triangle_count = 0 + normal_mapped_object_count = 0 + used_normal_texture_ids: set[str] = set() + lightmapped_object_count = 0 + used_lightmap_texture_ids: set[str] = set() + retail_world_frame_object_count = 0 + default_excluded_static_model_asset_ids = ( + {"0xd4538d2e1da5434f"} + if manifest.get("format") == "skate2-bam-cache-v1" + else set() + ) + excluded_static_model_asset_ids = default_excluded_static_model_asset_ids | { + str(asset_id).lower() + for asset_id in manifest.get("presentation_model_policy", {}).get( + "excluded_asset_ids", + [], + ) + } + excluded_static_mesh_parts = 0 + for model_entry in manifest["models"]: + asset_id = model_entry["asset_id"] + if str(asset_id).lower() in excluded_static_model_asset_ids: + excluded_static_mesh_parts += len(model_entry["meshes"]) + continue + source_stem = Path(model_entry["stream_file"]).stem + cell_collection = presentation.children.get(source_stem) + if cell_collection is None: + cell_collection = _new_child_collection(presentation, source_stem) + asset_collection = _new_child_collection( + cell_collection, + f"MODEL_{asset_id[2:]}", + ) + npz_path = cache_root / model_entry["npz"] + with numpy.load(npz_path) as arrays: + for mesh_entry in model_entry["meshes"]: + mesh_index = mesh_entry["index"] + vertices = _runtime_to_blender(arrays[f"vertices_{mesh_index}"]) + faces = arrays[f"faces_{mesh_index}"] + uvs = ( + arrays[f"uvs_{mesh_index}"] + if f"uvs_{mesh_index}" in arrays + else None + ) + lightmap_uvs = ( + arrays[f"lightmap_uvs_{mesh_index}"] + if f"lightmap_uvs_{mesh_index}" in arrays + else None + ) + decal_uvs = ( + arrays[f"decal_uvs_{mesh_index}"] + if f"decal_uvs_{mesh_index}" in arrays + else None + ) + retail_frame_available = all( + name in arrays + for name in ( + f"retail_normals_{mesh_index}", + f"retail_tangents_{mesh_index}", + f"retail_tangent_handedness_{mesh_index}", + ) + ) + if retail_frame_available: + normals = _runtime_to_blender( + arrays[f"retail_normals_{mesh_index}"] + ) + retail_tangents = _runtime_to_blender( + arrays[f"retail_tangents_{mesh_index}"] + ) + retail_handedness = numpy.asarray( + arrays[ + f"retail_tangent_handedness_{mesh_index}" + ], + dtype=numpy.float32, + ) + else: + normals = ( + _runtime_to_blender( + arrays[f"normals_{mesh_index}"] + ) + if f"normals_{mesh_index}" in arrays + else None + ) + retail_tangents = None + retail_handedness = None + + object_name = _safe_name( + mesh_entry["material_name"] or mesh_entry["name"], + f"{asset_id[2:]}_{mesh_index:02d}", + ) + mesh_data = bpy.data.meshes.new(f"{object_name}_MESH") + mesh_data.from_pydata(vertices.tolist(), [], faces.tolist()) + mesh_data.update(calc_edges=True) + if retail_frame_available: + _set_point_vector_attribute( + mesh_data, + RETAIL_NORMAL_ATTRIBUTE, + normals, + ) + _set_point_vector_attribute( + mesh_data, + RETAIL_TANGENT_ATTRIBUTE, + retail_tangents, + ) + _set_point_float_attribute( + mesh_data, + RETAIL_HANDEDNESS_ATTRIBUTE, + retail_handedness, + ) + retail_world_frame_object_count += 1 + + if uvs is not None: + uv_layer = mesh_data.uv_layers.new(name="UVMap") + for loop in mesh_data.loops: + uv = uvs[loop.vertex_index] + uv_layer.data[loop.index].uv = ( + float(uv[0]), + 1.0 - float(uv[1]), + ) + if lightmap_uvs is not None: + lightmap_layer = mesh_data.uv_layers.new(name="Lightmap") + shader_name = str(mesh_entry.get("shader_name") or "") + absolute_unwrap = shader_name != "ocean.default" + for loop in mesh_data.loops: + uv = lightmap_uvs[loop.vertex_index] + u = float(uv[0]) + v = float(uv[1]) + if absolute_unwrap: + u = abs(u) + v = abs(v) + lightmap_layer.data[loop.index].uv = (u, 1.0 - v) + if decal_uvs is not None: + decal_layer = mesh_data.uv_layers.new(name="Decal") + for loop in mesh_data.loops: + uv = decal_uvs[loop.vertex_index] + decal_layer.data[loop.index].uv = ( + float(uv[0]), + 1.0 - float(uv[1]), + ) + + for polygon in mesh_data.polygons: + polygon.use_smooth = True + if normals is not None: + mesh_data.normals_split_custom_set_from_vertices( + normals.tolist() + ) + + obj = bpy.data.objects.new(object_name, mesh_data) + asset_collection.objects.link(obj) + texture_id = mesh_entry["texture_id"] + alpha_mode = int(mesh_entry.get("alpha_mode", 0)) + retail_texture_ids = mesh_entry.get( + "retail_texture_ids", + {}, + ) + source_lightmap_texture_id = str( + retail_texture_ids.get("lightmap", "") + ).lower() + shader_name = str(mesh_entry.get("shader_name") or "") + lightmap_texture_id = ( + source_lightmap_texture_id + if source_lightmap_texture_id + and lightmap_uvs is not None + and not shader_name.startswith(("water.", "ocean.")) + else "" + ) + source_normal_texture_id = str( + retail_texture_ids.get("normal", "") + ).lower() + normal_texture_id = ( + source_normal_texture_id + if source_normal_texture_id + and source_normal_texture_id + not in excluded_normal_texture_ids + else "" + ) + retail_parameters = mesh_entry.get( + "retail_parameters", {} + ) + retail_source = { + "asset_id": asset_id, + "stream_file": model_entry["stream_file"], + "mesh_index": mesh_index, + "vertex_stride": mesh_entry["vertex_stride"], + "attributes": mesh_entry.get("attributes", []), + "source_offsets": mesh_entry["source_offsets"], + "bounds": mesh_entry.get("bounds", {}), + "vertex_count": mesh_entry["vertex_count"], + "triangle_count": mesh_entry["triangle_count"], + "retail_world_frame": mesh_entry.get( + "retail_world_frame" + ), + } + material_key = ( + asset_id, + mesh_index, + str(mesh_entry.get("retail_material_guid", "")), + str(mesh_entry.get("retail_material_handle", "")), + int( + mesh_entry.get( + "retail_material_group_index", -1 + ) + ), + shader_name, + json.dumps( + retail_texture_ids, + sort_keys=True, + separators=(",", ":"), + ), + json.dumps( + retail_parameters, + sort_keys=True, + separators=(",", ":"), + ), + alpha_mode, + ) + material = materials.get(material_key) + if material is None: + image = images.get(texture_id) if texture_id else None + normal_image = ( + images.get(normal_texture_id) + if normal_texture_id + else None + ) + lightmap_image = ( + images.get(lightmap_texture_id) + if lightmap_texture_id + else None + ) + material = _new_material( + texture_id or "NO_DIFFUSE", + image, + alpha_mode, + lightmap_texture_id=lightmap_texture_id, + lightmap_image=lightmap_image, + normal_texture_id=normal_texture_id, + normal_image=normal_image, + shader_name=shader_name, + retail_material_guid=str( + mesh_entry.get( + "retail_material_guid", "" + ) + ), + retail_material_handle=str( + mesh_entry.get( + "retail_material_handle", "" + ) + ), + retail_material_group_index=int( + mesh_entry.get( + "retail_material_group_index", -1 + ) + ), + retail_texture_ids={ + str(key): str(value).lower() + for key, value in retail_texture_ids.items() + }, + retail_parameters=retail_parameters, + retail_source=retail_source, + fallback=image is None and bool(texture_id), + ) + entry = ( + manifest["textures"].get(texture_id) + if texture_id + else None + ) + if entry is not None: + material["skate3_stream_asset_id"] = entry[ + "stream_asset_id" + ] + material["skate3_stream_file"] = entry[ + "stream_file" + ] + materials[material_key] = material + mesh_data.materials.append(material) + if normal_texture_id: + normal_mapped_object_count += 1 + used_normal_texture_ids.add(normal_texture_id) + if lightmap_texture_id: + lightmapped_object_count += 1 + used_lightmap_texture_ids.add(lightmap_texture_id) + obj["skate3_asset_id"] = asset_id + obj["skate3_stream_file"] = model_entry["stream_file"] + obj["skate3_mesh_index"] = mesh_index + obj["skate3_material_name"] = mesh_entry["material_name"] or "" + obj["skate3_retail_material_guid"] = mesh_entry.get( + "retail_material_guid", + "", + ) + obj["skate3_retail_material_handle"] = mesh_entry.get( + "retail_material_handle", + "", + ) + obj["skate3_retail_material_group_index"] = int( + mesh_entry.get("retail_material_group_index", -1) + ) + obj["skate3_texture_id"] = texture_id or "" + obj["skate3_retail_world_frame"] = retail_frame_available + obj["skate3_lightmap_texture_id"] = lightmap_texture_id + obj["skate3_source_lightmap_texture_id"] = ( + source_lightmap_texture_id + ) + if source_lightmap_texture_id and not lightmap_texture_id: + obj["skate3_lightmap_exclusion"] = ( + "shader family does not consume a reliable static " + "lightmap" + if lightmap_uvs is not None + else "retail mesh has no secondary TEXCOORD" + ) + obj["skate3_normal_texture_id"] = normal_texture_id + obj["skate3_source_normal_texture_id"] = ( + source_normal_texture_id + ) + obj["skate3_shader_name"] = mesh_entry.get("shader_name") or "" + obj["skate3_retail_parameters"] = json.dumps( + retail_parameters, + sort_keys=True, + separators=(",", ":"), + ) + obj["skate3_retail_texture_ids"] = json.dumps( + retail_texture_ids, + sort_keys=True, + separators=(",", ":"), + ) + obj["skate3_texture_channel"] = ( + mesh_entry.get("texture_channel") or "" + ) + obj["skate3_alpha_mode"] = alpha_mode + obj["skate3_vertex_stride"] = mesh_entry["vertex_stride"] + obj["skate3_source_offsets"] = json.dumps( + mesh_entry["source_offsets"], + sort_keys=True, + ) + object_count += 1 + vertex_count += len(vertices) + triangle_count += len(faces) + + ( + grind_rail_count, + grind_segment_count, + cell_local_grind_count, + ambiguous_grind_count, + ) = _import_retail_grinds(manifest, retail_grinds) + ( + collision_mesh_count, + collision_surface_count, + collision_triangle_count, + ) = _import_retail_collision(manifest, cache_root, collision) + + for simulation_entry in manifest["simulation_assets"]: + empty = bpy.data.objects.new( + f"SIM_{simulation_entry['asset_id'][2:]}", + None, + ) + empty.empty_display_type = "CUBE" + empty.empty_display_size = 0.25 + empty.hide_render = True + empty["skate3_asset_id"] = simulation_entry["asset_id"] + empty["skate3_stream_file"] = simulation_entry["stream_file"] + empty["skate3_rx2"] = simulation_entry["rx2"] + empty["skate3_size"] = simulation_entry["size"] + collision.objects.link(empty) + + info = bpy.data.objects.new(f"{scene_name} Import Status", None) + info.empty_display_type = "PLAIN_AXES" + info.empty_display_size = 2.0 + info["status"] = ( + "Presentation geometry, diffuse textures, exact retail lightmaps, " + "conservative retail normal textures, exact retail collision, and " + "retail grind splines imported. Original RX2 assets and all material " + "channel IDs are preserved in the cache." + ) + info["source_manifest"] = str(manifest_path) + metadata.objects.link(info) + + scene["skate3_map_name"] = manifest["map_name"] + scene["skate3_package_name"] = manifest["package_name"] + scene["skate3_district_name"] = manifest["district_name"] + scene["skate3_manifest"] = str(manifest_path) + scene["skate3_import_format"] = manifest["format"] + scene["skate3_collision_status"] = ( + f"{collision_triangle_count} exact retail ClusteredMesh triangles " + f"across {collision_surface_count} packed surfaces" + ) + scene["skate3_grind_status"] = ( + f"{grind_rail_count} exact retail grind rails with " + f"{grind_segment_count} native cubic segments; " + f"{cell_local_grind_count} cell-local rails placed in world space; " + f"{ambiguous_grind_count} boundary rails preserved unchanged" + ) + scene["skate3_excluded_static_model_status"] = ( + f"{excluded_static_mesh_parts} unplaced static prop mesh parts " + "excluded by import policy" + ) + scene["skate3_normal_status"] = ( + f"{normal_mapped_object_count} mesh parts use " + f"{len(used_normal_texture_ids)} conventional retail normal maps" + ) + scene["skate3_lightmap_status"] = ( + f"{lightmapped_object_count} mesh parts use " + f"{len(used_lightmap_texture_ids)} exact retail lightmap pages" + ) + scene["skate3_retail_world_frame_status"] = ( + f"{retail_world_frame_object_count} mesh parts preserve exact retail " + "normal, tangent, and handedness data" + ) + return { + "objects": object_count, + "vertices": vertex_count, + "triangles": triangle_count, + "textures": len(images), + "materials": len(materials), + "normal_mapped_objects": normal_mapped_object_count, + "normal_textures": len(used_normal_texture_ids), + "lightmapped_objects": lightmapped_object_count, + "lightmap_textures": len(used_lightmap_texture_ids), + "retail_world_frames": retail_world_frame_object_count, + "grind_rails": grind_rail_count, + "grind_segments": grind_segment_count, + "collision_meshes": collision_mesh_count, + "collision_surfaces": collision_surface_count, + "collision_triangles": collision_triangle_count, + "simulation_assets": len(manifest["simulation_assets"]), + "expected_objects": ( + manifest["summary"]["mesh_parts"] + - excluded_static_mesh_parts + ), + } + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 2: + raise SystemExit( + "usage: blender --background --python import_hawaiian_dream.py -- " + "MANIFEST OUTPUT_BLEND" + ) + manifest_path = Path(arguments[0]).resolve() + output_blend = Path(arguments[1]).resolve() + summary = build_scene(manifest_path) + if summary["objects"] != summary["expected_objects"]: + raise RuntimeError( + f"created {summary['objects']} objects; " + f"expected {summary['expected_objects']}" + ) + output_blend.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.file.pack_all() + bpy.ops.wm.save_as_mainfile(filepath=str(output_blend)) + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/import_mega_park.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_mega_park.py new file mode 100644 index 00000000..76b15062 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_mega_park.py @@ -0,0 +1,376 @@ +"""Build and save a textured Mega Park Blender scene from the prepared cache.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +import re +import sys + +import bpy +from mathutils import Vector +import numpy + + +def _safe_name(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") + return (cleaned or fallback)[:120] + + +def _runtime_to_blender(values: numpy.ndarray) -> numpy.ndarray: + converted = values[:, [0, 2, 1]].copy() + converted[:, 1] *= -1.0 + return converted + + +def _look_at(camera: bpy.types.Object, target: Vector) -> None: + direction = target - camera.location + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def _clear_scene() -> None: + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for collection in list(bpy.data.collections): + if collection.name != "Collection": + bpy.data.collections.remove(collection) + default = bpy.data.collections.get("Collection") + if default is not None: + bpy.data.collections.remove(default) + + +def _new_child_collection( + parent: bpy.types.Collection | bpy.types.Scene, name: str +) -> bpy.types.Collection: + collection = bpy.data.collections.new(name) + parent.collection.children.link(collection) if isinstance( + parent, bpy.types.Scene + ) else parent.children.link(collection) + return collection + + +def _load_materials( + manifest: dict[str, object], cache_root: Path +) -> dict[str, bpy.types.Material]: + materials: dict[str, bpy.types.Material] = {} + for texture_id, entry_value in manifest["textures"].items(): + entry = entry_value + image_path = cache_root / entry["png"] + image = bpy.data.images.load(str(image_path), check_existing=True) + image.name = texture_id + + material = bpy.data.materials.new(f"MAT_{texture_id}") + material.use_nodes = True + material.use_backface_culling = True + nodes = material.node_tree.nodes + links = material.node_tree.links + principled = nodes.get("Principled BSDF") + texture = nodes.new("ShaderNodeTexImage") + texture.name = f"TEX_{texture_id}" + texture.label = texture_id + texture.image = image + texture.interpolation = "Linear" + links.new(texture.outputs["Color"], principled.inputs["Base Color"]) + if "Alpha" in principled.inputs: + links.new(texture.outputs["Alpha"], principled.inputs["Alpha"]) + principled.inputs["Roughness"].default_value = 0.68 + principled.inputs["Metallic"].default_value = 0.0 + if entry["format"] in {"DXT3", "DXT5"}: + try: + material.surface_render_method = "DITHERED" + except Exception: + try: + material.blend_method = "HASHED" + except Exception: + pass + material["skate3_texture_id"] = texture_id + material["skate3_stream_asset_id"] = entry["stream_asset_id"] + materials[texture_id] = material + return materials + + +def build_scene(manifest_path: Path) -> dict[str, int]: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + cache_root = manifest_path.parent + _clear_scene() + + scene = bpy.context.scene + scene.name = "DIST_MegaPark" + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + scene.render.engine = "BLENDER_EEVEE" + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.film_transparent = False + scene.view_settings.look = "AgX - Medium High Contrast" + scene.view_settings.exposure = 1.0 + + world = bpy.data.worlds.new("Mega Park World") + world.use_nodes = True + background = world.node_tree.nodes.get("Background") + background.inputs["Color"].default_value = (0.025, 0.035, 0.05, 1.0) + background.inputs["Strength"].default_value = 0.35 + scene.world = world + + root = _new_child_collection(scene, "DIST_MegaPark") + presentation = _new_child_collection(root, "Presentation") + collision = _new_child_collection(root, "Collision_RAW") + metadata = _new_child_collection(root, "Metadata") + materials = _load_materials(manifest, cache_root) + + object_count = 0 + vertex_count = 0 + triangle_count = 0 + + for model_entry in manifest["models"]: + asset_id = model_entry["asset_id"] + asset_collection = _new_child_collection( + presentation, f"MODEL_{asset_id[2:]}" + ) + npz_path = cache_root / model_entry["npz"] + with numpy.load(npz_path) as arrays: + for mesh_entry in model_entry["meshes"]: + mesh_index = mesh_entry["index"] + runtime_vertices = arrays[f"vertices_{mesh_index}"] + vertices = _runtime_to_blender(runtime_vertices) + faces = arrays[f"faces_{mesh_index}"] + uvs = ( + arrays[f"uvs_{mesh_index}"] + if f"uvs_{mesh_index}" in arrays + else None + ) + + object_name = _safe_name( + mesh_entry["material_name"] or mesh_entry["name"], + f"{asset_id[2:]}_{mesh_index:02d}", + ) + mesh_data = bpy.data.meshes.new(f"{object_name}_MESH") + mesh_data.from_pydata( + vertices.tolist(), + [], + faces.tolist(), + ) + mesh_data.update(calc_edges=True) + + if uvs is not None: + uv_layer = mesh_data.uv_layers.new(name="UVMap") + for loop in mesh_data.loops: + uv = uvs[loop.vertex_index] + uv_layer.data[loop.index].uv = ( + float(uv[0]), + 1.0 - float(uv[1]), + ) + + for polygon in mesh_data.polygons: + polygon.use_smooth = True + + obj = bpy.data.objects.new(object_name, mesh_data) + asset_collection.objects.link(obj) + texture_id = mesh_entry["texture_id"] + if texture_id in materials: + mesh_data.materials.append(materials[texture_id]) + obj["skate3_asset_id"] = asset_id + obj["skate3_mesh_index"] = mesh_index + obj["skate3_material_name"] = mesh_entry["material_name"] or "" + obj["skate3_texture_id"] = texture_id or "" + obj["skate3_vertex_stride"] = mesh_entry["vertex_stride"] + obj["skate3_source_offsets"] = json.dumps( + mesh_entry["source_offsets"], sort_keys=True + ) + object_count += 1 + vertex_count += len(vertices) + triangle_count += len(faces) + + for simulation_entry in manifest["simulation_assets"]: + empty = bpy.data.objects.new( + f"SIM_{simulation_entry['asset_id'][2:]}", + None, + ) + empty.empty_display_type = "CUBE" + empty.empty_display_size = 0.35 + empty.hide_render = True + empty["skate3_asset_id"] = simulation_entry["asset_id"] + empty["skate3_rx2"] = simulation_entry["rx2"] + empty["skate3_size"] = simulation_entry["size"] + collision.objects.link(empty) + + info = bpy.data.objects.new("Mega Park Import Status", None) + info.empty_display_type = "PLAIN_AXES" + info.empty_display_size = 2.0 + info["status"] = ( + "Presentation geometry and diffuse textures imported. " + "Simulation RX2 assets preserved but collision decoding is pending." + ) + info["source_manifest"] = str(manifest_path) + metadata.objects.link(info) + + summary = manifest["summary"] + runtime_minimum = numpy.min( + numpy.asarray( + [model["bounds"]["minimum"] for model in manifest["models"]], + dtype=numpy.float32, + ), + axis=0, + ) + runtime_maximum = numpy.max( + numpy.asarray( + [model["bounds"]["maximum"] for model in manifest["models"]], + dtype=numpy.float32, + ), + axis=0, + ) + corners = numpy.asarray( + [ + [x, y, z] + for x in (runtime_minimum[0], runtime_maximum[0]) + for y in (runtime_minimum[1], runtime_maximum[1]) + for z in (runtime_minimum[2], runtime_maximum[2]) + ], + dtype=numpy.float32, + ) + blender_corners = _runtime_to_blender(corners) + minimum = blender_corners.min(axis=0) + maximum = blender_corners.max(axis=0) + center = Vector(((minimum + maximum) * 0.5).tolist()) + dimensions = maximum - minimum + extent = float(max(dimensions)) + + exterior_data = bpy.data.cameras.new("Mega Park Exterior Camera") + exterior = bpy.data.objects.new("Mega Park Exterior Camera", exterior_data) + root.objects.link(exterior) + exterior.location = center + Vector( + (extent * 0.72, -extent * 0.95, extent * 0.58) + ) + exterior_data.lens = 48.0 + exterior_data.clip_start = 0.1 + exterior_data.clip_end = max(2000.0, extent * 10.0) + _look_at(exterior, center) + + interior_data = bpy.data.cameras.new("Mega Park Interior Camera") + interior = bpy.data.objects.new("Mega Park Interior Camera", interior_data) + root.objects.link(interior) + interior.location = Vector( + ( + float(minimum[0] + dimensions[0] * 0.18), + float(minimum[1] + dimensions[1] * 0.28), + float(minimum[2] + max(9.0, dimensions[2] * 0.13)), + ) + ) + interior_target = Vector( + ( + float(minimum[0] + dimensions[0] * 0.72), + float(minimum[1] + dimensions[1] * 0.58), + float(minimum[2] + max(7.0, dimensions[2] * 0.09)), + ) + ) + interior_data.lens = 31.0 + interior_data.clip_start = 0.05 + interior_data.clip_end = max(2000.0, extent * 10.0) + _look_at(interior, interior_target) + scene.camera = interior + + sun_data = bpy.data.lights.new("Mega Park Sun", type="SUN") + sun_data.energy = 3.0 + sun_data.angle = math.radians(18.0) + sun = bpy.data.objects.new("Mega Park Sun", sun_data) + root.objects.link(sun) + sun.rotation_euler = (math.radians(32.0), math.radians(-18.0), math.radians(-28.0)) + + area_data = bpy.data.lights.new("Mega Park Fill", type="AREA") + area_data.energy = 1800.0 + area_data.shape = "DISK" + area_data.size = extent * 0.45 + area = bpy.data.objects.new("Mega Park Fill", area_data) + root.objects.link(area) + area.location = center + Vector((-extent * 0.35, extent * 0.2, extent * 0.7)) + _look_at(area, center) + + interior_key_data = bpy.data.lights.new("Mega Park Interior Key", type="AREA") + interior_key_data.energy = 5200.0 + interior_key_data.shape = "DISK" + interior_key_data.size = extent * 0.34 + interior_key_data.use_shadow = False + interior_key = bpy.data.objects.new("Mega Park Interior Key", interior_key_data) + root.objects.link(interior_key) + interior_key.location = Vector( + ( + float(center.x), + float(center.y), + float(minimum[2] + dimensions[2] * 0.62), + ) + ) + _look_at( + interior_key, + Vector((float(center.x), float(center.y), float(minimum[2]))), + ) + + camera_fill_data = bpy.data.lights.new("Mega Park Camera Fill", type="AREA") + camera_fill_data.energy = 2600.0 + camera_fill_data.shape = "DISK" + camera_fill_data.size = extent * 0.18 + camera_fill_data.use_shadow = False + camera_fill = bpy.data.objects.new("Mega Park Camera Fill", camera_fill_data) + root.objects.link(camera_fill) + camera_fill.location = interior.location + Vector((0.0, 0.0, extent * 0.08)) + _look_at(camera_fill, interior_target) + + interior_sun_data = bpy.data.lights.new("Mega Park Interior Sun", type="SUN") + interior_sun_data.energy = 2.2 + interior_sun_data.angle = math.radians(35.0) + interior_sun_data.use_shadow = False + interior_sun = bpy.data.objects.new( + "Mega Park Interior Sun", interior_sun_data + ) + root.objects.link(interior_sun) + interior_sun.rotation_euler = ( + math.radians(18.0), + math.radians(-12.0), + math.radians(142.0), + ) + + scene["skate3_manifest"] = str(manifest_path) + scene["skate3_import_format"] = manifest["format"] + scene["skate3_collision_status"] = "raw assets preserved; decoder pending" + return { + "objects": object_count, + "vertices": vertex_count, + "triangles": triangle_count, + "textures": len(materials), + "simulation_assets": len(manifest["simulation_assets"]), + "expected_objects": summary["mesh_parts"], + } + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) < 2: + raise SystemExit( + "usage: blender --background --python import_mega_park.py -- " + "MANIFEST OUTPUT_BLEND [PREVIEW_PNG]" + ) + manifest_path = Path(arguments[0]).resolve() + output_blend = Path(arguments[1]).resolve() + preview_png = Path(arguments[2]).resolve() if len(arguments) > 2 else None + + summary = build_scene(manifest_path) + if summary["objects"] != summary["expected_objects"]: + raise RuntimeError( + f"created {summary['objects']} objects; " + f"expected {summary['expected_objects']}" + ) + output_blend.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.file.pack_all() + bpy.ops.wm.save_as_mainfile(filepath=str(output_blend)) + if preview_png is not None: + preview_png.parent.mkdir(parents=True, exist_ok=True) + bpy.context.scene.render.filepath = str(preview_png) + bpy.ops.render.render(write_still=True) + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/import_university.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_university.py new file mode 100644 index 00000000..d79123e9 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/import_university.py @@ -0,0 +1,38 @@ +"""Build and save the official Skate 3 University district.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import bpy + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from import_hawaiian_dream import build_scene + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 2: + raise SystemExit( + "usage: blender --background --python import_university.py -- " + "MANIFEST OUTPUT_BLEND" + ) + manifest_path = Path(arguments[0]).resolve() + output_blend = Path(arguments[1]).resolve() + summary = build_scene(manifest_path) + if summary["objects"] != summary["expected_objects"]: + raise RuntimeError( + f"created {summary['objects']} objects; " + f"expected {summary['expected_objects']}" + ) + output_blend.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.file.pack_all() + bpy.ops.wm.save_as_mainfile(filepath=str(output_blend)) + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/inspect_blend_region.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/inspect_blend_region.py new file mode 100644 index 00000000..dd2e4688 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/inspect_blend_region.py @@ -0,0 +1,113 @@ +"""Print export-relevant Blender object details near a runtime-space point.""" + +from __future__ import annotations + +import argparse +import math +import sys + +import bpy +from mathutils import Vector + + +def _arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-center", nargs=3, type=float, required=True) + parser.add_argument("--radius", type=float, default=50.0) + return parser.parse_args( + sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + ) + + +def _runtime_position(blender_position: Vector) -> Vector: + return Vector( + ( + blender_position.x, + blender_position.z, + -blender_position.y, + ) + ) + + +def _object_runtime_bounds( + obj: bpy.types.Object, +) -> tuple[Vector, Vector, Vector]: + corners = [ + _runtime_position(obj.matrix_world @ Vector(corner)) + for corner in obj.bound_box + ] + minimum = Vector( + tuple(min(corner[index] for corner in corners) for index in range(3)) + ) + maximum = Vector( + tuple(max(corner[index] for corner in corners) for index in range(3)) + ) + return minimum, maximum, (minimum + maximum) * 0.5 + + +def _public_properties(value: object) -> dict[str, object]: + if not hasattr(value, "keys"): + return {} + omitted = { + "_RNA_UI", + "skate3_retail_grind_segment_payload", + "skate3_retail_parameters", + "skate3_retail_source", + "skate3_retail_texture_ids", + } + return {str(key): value[key] for key in value.keys() if key not in omitted} + + +def main() -> None: + args = _arguments() + center = Vector(args.runtime_center) + matches: list[tuple[float, bpy.types.Object, Vector, Vector, Vector]] = [] + for obj in bpy.context.scene.objects: + if obj.type not in {"MESH", "CURVE"}: + continue + minimum, maximum, object_center = _object_runtime_bounds(obj) + distance = (object_center - center).length + if distance <= args.radius: + matches.append((distance, obj, minimum, maximum, object_center)) + + print( + f"BLEND_REGION center={tuple(center)} radius={args.radius} " + f"matches={len(matches)}" + ) + for distance, obj, minimum, maximum, object_center in sorted(matches): + print( + f"OBJECT distance={distance:.3f} type={obj.type} " + f"name={obj.name_full!r}" + ) + print( + f" runtime_center={tuple(round(value, 3) for value in object_center)} " + f"bounds_min={tuple(round(value, 3) for value in minimum)} " + f"bounds_max={tuple(round(value, 3) for value in maximum)}" + ) + print(f" object_properties={_public_properties(obj)!r}") + print(f" data_properties={_public_properties(obj.data)!r}") + if obj.type == "MESH": + mesh = obj.data + print( + f" mesh vertices={len(mesh.vertices)} loops={len(mesh.loops)} " + f"polygons={len(mesh.polygons)} uv_layers=" + f"{[layer.name for layer in mesh.uv_layers]!r} " + f"attributes={[(attribute.name, attribute.domain, attribute.data_type) for attribute in mesh.attributes]!r}" + ) + for slot in obj.material_slots: + material = slot.material + if material is None: + continue + print( + f" material={material.name_full!r} " + f"properties={_public_properties(material)!r}" + ) + else: + print( + f" curve splines={len(obj.data.splines)} " + f"bevel_depth={obj.data.bevel_depth}" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_skate2_owned.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_skate2_owned.py new file mode 100644 index 00000000..1bc5d3af --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_skate2_owned.py @@ -0,0 +1,269 @@ +"""Prepare the extracted Skate 2 city scene for the owned-world exporter.""" + +from __future__ import annotations + +from array import array +import json +import math +from pathlib import Path +import sys + +import bpy + + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from prepare_university_owned import ( + GROUP_1, + GROUP_2, + GROUP_3, + GROUP_4, + GROUP_5, + NO_COLLISION_MARKERS, + SPAWN, + _configure_material, + _default_material, + _ensure_export_uvs, + _new_group, + _presentation_only, + _surface_hits, +) + + +# A broad, nearly level sidewalk confirmed against both presentation geometry +# and the exact retail collision mesh. +SPAWN_RUNTIME_XZ = (0.0, 50.0) +SPAWN_TARGET_HEIGHT = -26.917 + + +def _configure_skate2_material(material: bpy.types.Material) -> int: + """Configure one material and omit retail bindings absent from BAM.""" + + _configure_material(material) + raw_texture_ids = json.loads( + str(material.get("skate3_retail_texture_ids", "{}")) + ) + if not isinstance(raw_texture_ids, dict): + raise RuntimeError( + f"{material.name!r} has invalid retail texture metadata" + ) + available: dict[str, str] = {} + excluded: dict[str, str] = {} + for semantic, source_id in raw_texture_ids.items(): + normalized_id = str(source_id).lower() + if bpy.data.images.get(normalized_id) is None: + excluded[str(semantic)] = normalized_id + else: + available[str(semantic)] = normalized_id + material["skate3_retail_texture_ids"] = json.dumps( + available, + sort_keys=True, + separators=(",", ":"), + ) + material["skate2_excluded_retail_texture_ids"] = json.dumps( + excluded, + sort_keys=True, + separators=(",", ":"), + ) + return len(excluded) + + +def _sanitize_export_uvs(mesh: bpy.types.Mesh) -> int: + """Replace Skate 2's FLT_MAX unused-UV sentinel in the owned copy.""" + + replaced = 0 + for layer in mesh.uv_layers: + values = array("f", [0.0]) * (len(mesh.loops) * 2) + layer.data.foreach_get("uv", values) + changed = False + for index, value in enumerate(values): + if not math.isfinite(value) or abs(value) >= 1.0e30: + values[index] = 0.0 + replaced += 1 + changed = True + if changed: + layer.data.foreach_set("uv", values) + if replaced: + mesh["skate2_sanitized_uv_sentinel_values"] = replaced + return replaced + + +def _create_spawn() -> tuple[float, float, float]: + old = bpy.data.objects.get(SPAWN) + if old is not None: + bpy.data.objects.remove(old, do_unlink=True) + x, runtime_z = SPAWN_RUNTIME_XZ + candidates = [ + hit + for hit in _surface_hits(x, runtime_z) + if hit[1] >= 0.65 + and not any(marker in hit[2].lower() for marker in NO_COLLISION_MARKERS) + ] + if not candidates: + raise RuntimeError( + f"no upward Skate 2 surface found at runtime XZ {(x, runtime_z)}" + ) + surface_height, _normal_z, owner = min( + candidates, + key=lambda hit: abs(hit[0] - SPAWN_TARGET_HEIGHT), + ) + vertices = [ + (-2.0, -2.0, 0.0), + (2.0, -2.0, 0.0), + (2.0, 2.0, 0.0), + (-2.0, 2.0, 0.0), + (0.0, -3.0, 0.0), + ] + mesh = bpy.data.meshes.new(f"{SPAWN}_MESH") + mesh.from_pydata(vertices, [], [(0, 1, 2, 3), (0, 4, 1)]) + spawn = bpy.data.objects.new(SPAWN, mesh) + bpy.context.scene.collection.objects.link(spawn) + spawn.location = (x, -runtime_z, surface_height + 1.0) + spawn.rotation_euler.z = math.radians(-90.0) + spawn.hide_render = True + spawn["skate2_surface_object"] = owner + return (x, surface_height + 1.0, runtime_z) + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 1: + raise SystemExit( + "usage: blender --background Skate_2_New_San_Vanelona.blend " + "--python prepare_skate2_owned.py -- OUTPUT_BLEND" + ) + output = Path(arguments[0]).resolve() + group_1 = _new_group(GROUP_1) + group_2 = _new_group(GROUP_2) + group_3 = _new_group(GROUP_3) + group_4 = _new_group(GROUP_4) + _new_group(GROUP_5) + fallback = _default_material() + _configure_skate2_material(fallback) + + mesh_objects = sorted( + (obj for obj in bpy.data.objects if obj.type == "MESH"), + key=lambda obj: obj.name_full, + ) + material_ids: set[int] = set() + collidable = 0 + presentation_only = 0 + triangle_count = 0 + retail_collision_objects = 0 + retail_collision_triangles = 0 + excluded_retail_texture_bindings = 0 + sanitized_uv_sentinel_values = 0 + for object_index, obj in enumerate(mesh_objects, 1): + if obj.name == SPAWN: + continue + if bool(obj.get("skate3_retail_collision", False)): + if not obj.data.materials: + raise RuntimeError( + f"{obj.name!r} has no retail collision material" + ) + material = obj.data.materials[0] + obj["ow_material"] = material.name + group_2.objects.link(obj) + retail_collision_objects += 1 + retail_collision_triangles += len(obj.data.polygons) + continue + if not obj.data.materials: + obj.data.materials.append(fallback) + require_retail_lightmap = any( + material is not None + and bool(material.get("skate3_lightmap_texture_id", "")) + for material in obj.data.materials + ) + _ensure_export_uvs( + obj.data, + require_retail_lightmap=require_retail_lightmap, + ) + sanitized_uv_sentinel_values += _sanitize_export_uvs(obj.data) + for material in obj.data.materials: + if ( + material is not None + and material.as_pointer() not in material_ids + ): + material_ids.add(material.as_pointer()) + excluded_retail_texture_bindings += ( + _configure_skate2_material(material) + ) + obj["ow_material"] = obj.data.materials[0].name + triangle_count += len(obj.data.polygons) + if _presentation_only(obj): + group_3.objects.link(obj) + presentation_only += 1 + else: + group_1.objects.link(obj) + collidable += 1 + if object_index % 5_000 == 0: + print( + f"Prepared {object_index}/{len(mesh_objects)} mesh objects", + flush=True, + ) + + grind_objects = sorted( + ( + obj + for obj in bpy.data.objects + if obj.type == "CURVE" + and bool(obj.get("skate3_retail_grind", False)) + ), + key=lambda obj: obj.name_full, + ) + grind_segments = 0 + for obj in grind_objects: + group_4.objects.link(obj) + grind_segments += int(obj["skate3_retail_grind_segment_count"]) + + spawn_runtime = _create_spawn() + scene = bpy.context.scene + scene["ow_map_name"] = "Skate 2 — New San Vanelona" + scene["ow_cycle_seconds"] = 0.0 + scene["ow_start_hour"] = 11.0 + scene["ow_end_hour"] = 18.0 + scene["ow_cycle_ping_pong"] = False + scene["ow_sky_zenith"] = (0.10, 0.36, 0.75) + scene["ow_sky_horizon"] = (0.64, 0.82, 1.0) + scene["ow_sky_nadir"] = (0.18, 0.24, 0.30) + scene["ow_day_ambient"] = 0.34 + scene["ow_night_ambient"] = 0.10 + scene["skate2_collision_source"] = ( + "exact retail RenderWare ClusteredMesh triangles and packed surfaces" + ) + scene["skate2_grind_source"] = ( + "exact corrected retail Pegasus tSplineData cubic segment payloads" + ) + scene["skate2_spawn_runtime"] = spawn_runtime + output.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False) + print( + json.dumps( + { + "output": str(output), + "mesh_objects": len(mesh_objects), + "materials": len(material_ids), + "triangles": triangle_count, + "collidable_objects": collidable, + "presentation_only_objects": presentation_only, + "retail_collision_objects": retail_collision_objects, + "retail_collision_triangles": retail_collision_triangles, + "grind_rails": len(grind_objects), + "grind_segments": grind_segments, + "excluded_absent_retail_texture_bindings": ( + excluded_retail_texture_bindings + ), + "sanitized_uv_sentinel_values": ( + sanitized_uv_sentinel_values + ), + "spawn_runtime": spawn_runtime, + "collision_source": scene["skate2_collision_source"], + }, + indent=2, + ), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_university_owned.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_university_owned.py new file mode 100644 index 00000000..dcffa7ae --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/prepare_university_owned.py @@ -0,0 +1,358 @@ +"""Prepare the extracted University scene for the owned-world exporter.""" + +from __future__ import annotations + +from array import array +import json +import math +from pathlib import Path +import sys + +import bpy + + +GROUP_1 = "OW_GROUP_1_PRESENTATION_COLLISION" +GROUP_2 = "OW_GROUP_2_NO_PRESENTATION" +GROUP_3 = "OW_GROUP_3_NO_COLLISION" +GROUP_4 = "OW_GROUP_4_GRINDS" +GROUP_5 = "OW_GROUP_5_PATHING" +SPAWN = "OW_SPAWN" +NO_COLLISION_MARKERS = ( + "billboard", + "cloud", + "decal", + "foliage", + "leaf", + "leaves", + "particle", + "reflection", + "shadow", + "shrub", + "sky", + "tree", + "water", +) +# Flat wooden starting deck inside Super Ultra Mega Park. The height target +# prevents the ray cast from selecting terrain underneath the park structures. +SPAWN_RUNTIME_XZ = (330.0, -710.0) +SPAWN_TARGET_HEIGHT = 132.0 +SOLID_COLOR_TEXTURES = { + # The retail resource is an intentional 16x16 opaque black swatch. + # Store it as a material constant so the general exporter can continue + # rejecting accidentally blank texture decodes. + "0x0000119903e3870a": (0.0, 0.0, 0.0), +} + + +def _new_group(name: str) -> bpy.types.Collection: + existing = bpy.data.collections.get(name) + if existing is not None: + bpy.data.collections.remove(existing) + collection = bpy.data.collections.new(name) + bpy.context.scene.collection.children.link(collection) + return collection + + +def _default_material() -> bpy.types.Material: + material = bpy.data.materials.get("MAT_UNIVERSITY_FALLBACK") + if material is None: + material = bpy.data.materials.new("MAT_UNIVERSITY_FALLBACK") + material.diffuse_color = (0.62, 0.62, 0.62, 1.0) + return material + + +def _configure_material(material: bpy.types.Material) -> None: + texture_id = str(material.get("skate3_texture_id", "")) + solid_color = SOLID_COLOR_TEXTURES.get(texture_id) + if solid_color is not None: + retail_image = bpy.data.images.get(texture_id) + if retail_image is not None: + retail_image["skate3_allow_blank_rgb"] = True + retail_image["skate3_constant_rgb"] = solid_color + image = ( + bpy.data.images.get(texture_id) + if texture_id and solid_color is None + else None + ) + material["ow_flags"] = 1 + material["ow_friction"] = 0.82 + material["ow_restitution"] = 0.0 + material["ow_display_color"] = ( + solid_color + if solid_color is not None + else tuple(material.diffuse_color[:3]) + ) + material["ow_roughness"] = 0.68 + material["ow_emissive"] = 0.0 + material["ow_albedo_image"] = image.name if image is not None else "" + lightmap_texture_id = str( + material.get("skate3_lightmap_texture_id", "") + ) + lightmap_image = ( + bpy.data.images.get(lightmap_texture_id) + if lightmap_texture_id + else None + ) + if lightmap_texture_id and lightmap_image is None: + raise RuntimeError( + f"{material.name!r} lost retail lightmap image " + f"{lightmap_texture_id!r}" + ) + material["ow_lightmap_image"] = ( + lightmap_image.name if lightmap_image is not None else "" + ) + material["ow_lightmap_encoding"] = ( + "skate3_retail_sqrt_linear_over_4" + if lightmap_image is not None + else "" + ) + material["ow_baked_strength"] = ( + 1.0 if lightmap_image is not None else 0.0 + ) + normal_texture_id = str( + material.get("skate3_normal_texture_id", "") + ) + normal_image = ( + bpy.data.images.get(normal_texture_id) + if normal_texture_id + else None + ) + if normal_texture_id and normal_image is None: + raise RuntimeError( + f"{material.name!r} lost retail normal image " + f"{normal_texture_id!r}" + ) + material["ow_normal_image"] = ( + normal_image.name if normal_image is not None else "" + ) + material["ow_orm_image"] = "" + material["ow_emissive_image"] = "" + material["ow_alpha_mode"] = int(material.get("skate3_alpha_mode", 0)) + material["ow_alpha_cutoff"] = float( + material.get("skate3_alpha_cutoff", 0.5) + ) + material["ow_audio_surface"] = 3 + material["ow_physics_surface"] = 1 + material["ow_surface_pattern"] = 0 + # Presentation meshes keep their established export ordering, but retail + # ClusteredMesh objects are now the sole collision authority. + material["ow_collision_enabled"] = False + + +def _ensure_export_uvs( + mesh: bpy.types.Mesh, + *, + require_retail_lightmap: bool, +) -> None: + source = mesh.uv_layers.get("UVMap") + if source is None: + source = mesh.uv_layers.new(name="UVMap") + source_uvs = array("f", [0.0]) * (len(mesh.loops) * 2) + source.data.foreach_get("uv", source_uvs) + lightmap = mesh.uv_layers.get("Lightmap") + if lightmap is None: + if require_retail_lightmap: + raise RuntimeError( + f"{mesh.name!r} lost its exact retail Lightmap UV layer" + ) + lightmap = mesh.uv_layers.new(name="Lightmap") + lightmap.data.foreach_set("uv", source_uvs) + decal = mesh.uv_layers.get("Decal") + if decal is None: + decal = mesh.uv_layers.new(name="Decal") + decal.data.foreach_set("uv", source_uvs) + + +def _presentation_only(obj: bpy.types.Object) -> bool: + identity = " ".join( + ( + obj.name, + str(obj.get("skate3_material_name", "")), + ) + ).lower() + return any(marker in identity for marker in NO_COLLISION_MARKERS) + + +def _surface_hits( + x: float, + runtime_z: float, +) -> list[tuple[float, float, str]]: + scene = bpy.context.scene + depsgraph = bpy.context.evaluated_depsgraph_get() + origin = (x, -runtime_z, 400.0) + hits: list[tuple[float, float, str]] = [] + for _ in range(64): + hit, location, normal, _face, obj, _matrix = scene.ray_cast( + depsgraph, + origin, + (0.0, 0.0, -1.0), + distance=800.0, + ) + if not hit: + break + hits.append((float(location.z), float(normal.z), obj.name)) + origin = (x, -runtime_z, float(location.z) - 0.02) + return hits + + +def _create_spawn() -> tuple[float, float, float]: + old = bpy.data.objects.get(SPAWN) + if old is not None: + bpy.data.objects.remove(old, do_unlink=True) + x, runtime_z = SPAWN_RUNTIME_XZ + candidates = [ + hit + for hit in _surface_hits(x, runtime_z) + if hit[1] >= 0.65 + and not any(marker in hit[2].lower() for marker in NO_COLLISION_MARKERS) + ] + if not candidates: + raise RuntimeError( + f"no upward University surface found at runtime XZ {(x, runtime_z)}" + ) + surface_height, _normal_z, owner = min( + candidates, + key=lambda hit: abs(hit[0] - SPAWN_TARGET_HEIGHT), + ) + vertices = [ + (-2.0, -2.0, 0.0), + (2.0, -2.0, 0.0), + (2.0, 2.0, 0.0), + (-2.0, 2.0, 0.0), + (0.0, -3.0, 0.0), + ] + mesh = bpy.data.meshes.new(f"{SPAWN}_MESH") + mesh.from_pydata(vertices, [], [(0, 1, 2, 3), (0, 4, 1)]) + spawn = bpy.data.objects.new(SPAWN, mesh) + bpy.context.scene.collection.objects.link(spawn) + spawn.location = (x, -runtime_z, surface_height + 1.0) + spawn.rotation_euler.z = math.radians(-90.0) + spawn.hide_render = True + spawn["university_surface_object"] = owner + # Exporter converts Blender (x, y, z) to runtime (x, z, -y). + return (x, surface_height + 1.0, runtime_z) + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 1: + raise SystemExit( + "usage: blender --background DIST_University.blend --python " + "prepare_university_owned.py -- OUTPUT_BLEND" + ) + output = Path(arguments[0]).resolve() + group_1 = _new_group(GROUP_1) + group_2 = _new_group(GROUP_2) + group_3 = _new_group(GROUP_3) + group_4 = _new_group(GROUP_4) + _new_group(GROUP_5) + fallback = _default_material() + _configure_material(fallback) + + mesh_objects = sorted( + (obj for obj in bpy.data.objects if obj.type == "MESH"), + key=lambda obj: obj.name_full, + ) + material_ids: set[int] = set() + collidable = 0 + presentation_only = 0 + triangle_count = 0 + retail_collision_objects = 0 + retail_collision_triangles = 0 + for obj in mesh_objects: + if obj.name == SPAWN: + continue + if bool(obj.get("skate3_retail_collision", False)): + if not obj.data.materials: + raise RuntimeError( + f"{obj.name!r} has no retail collision material" + ) + material = obj.data.materials[0] + obj["ow_material"] = material.name + group_2.objects.link(obj) + retail_collision_objects += 1 + retail_collision_triangles += len(obj.data.polygons) + continue + if not obj.data.materials: + obj.data.materials.append(fallback) + require_retail_lightmap = any( + material is not None + and bool(material.get("skate3_lightmap_texture_id", "")) + for material in obj.data.materials + ) + _ensure_export_uvs( + obj.data, + require_retail_lightmap=require_retail_lightmap, + ) + for material in obj.data.materials: + if material is not None and material.as_pointer() not in material_ids: + material_ids.add(material.as_pointer()) + _configure_material(material) + obj["ow_material"] = obj.data.materials[0].name + triangle_count += len(obj.data.polygons) + if _presentation_only(obj): + group_3.objects.link(obj) + presentation_only += 1 + else: + group_1.objects.link(obj) + collidable += 1 + + grind_objects = sorted( + ( + obj + for obj in bpy.data.objects + if obj.type == "CURVE" + and bool(obj.get("skate3_retail_grind", False)) + ), + key=lambda obj: obj.name_full, + ) + grind_segments = 0 + for obj in grind_objects: + group_4.objects.link(obj) + grind_segments += int(obj["skate3_retail_grind_segment_count"]) + + spawn_runtime = _create_spawn() + scene = bpy.context.scene + scene["ow_map_name"] = "University District" + scene["ow_cycle_seconds"] = 0.0 + scene["ow_start_hour"] = 11.0 + scene["ow_end_hour"] = 18.0 + scene["ow_cycle_ping_pong"] = False + scene["ow_sky_zenith"] = (0.10, 0.36, 0.75) + scene["ow_sky_horizon"] = (0.64, 0.82, 1.0) + scene["ow_sky_nadir"] = (0.18, 0.24, 0.30) + scene["ow_day_ambient"] = 0.34 + scene["ow_night_ambient"] = 0.10 + scene["university_collision_source"] = ( + "exact retail RenderWare ClusteredMesh triangles and packed surfaces" + ) + scene["university_grind_source"] = ( + "exact retail Pegasus tSplineData cubic segment payloads" + ) + scene["university_spawn_runtime"] = spawn_runtime + output.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(output)) + print( + json.dumps( + { + "output": str(output), + "mesh_objects": len(mesh_objects), + "materials": len(material_ids), + "triangles": triangle_count, + "collidable_objects": collidable, + "presentation_only_objects": presentation_only, + "retail_collision_objects": retail_collision_objects, + "retail_collision_triangles": retail_collision_triangles, + "grind_rails": len(grind_objects), + "grind_segments": grind_segments, + "spawn_runtime": spawn_runtime, + "collision_source": scene["university_collision_source"], + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/repair_skate2_lightmaps.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/repair_skate2_lightmaps.py new file mode 100644 index 00000000..3b1e1872 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/repair_skate2_lightmaps.py @@ -0,0 +1,360 @@ +"""Bind complete Skate 2 monochrome lightmaps in an existing Blender file.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + +import bpy + + +RETAIL_ENCODING = "skate3_retail_sqrt_linear_over_4" + + +def _json_property(owner: object, name: str) -> dict[str, object]: + raw = str(owner.get(name, "{}")) + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError(f"{owner.name!r} has invalid {name}") + return value + + +def _source_lightmap(material: bpy.types.Material) -> str: + parameters = _json_property(material, "skate3_retail_parameters") + values = parameters.get("lightmap", []) + if not isinstance(values, list): + raise ValueError( + f"{material.name!r} has an invalid retail lightmap parameter" + ) + return str(values[0]) if values and values[0] else "" + + +def _bind_material( + material: bpy.types.Material, + source_name: str, + texture_id: str, + image: bpy.types.Image, + chromaticity_name: str, + chromaticity_texture_id: str, + component: int, +) -> None: + retail_ids = _json_property( + material, + "skate3_retail_texture_ids", + ) + retail_ids["lightmap"] = texture_id + retail_ids["chromaticity"] = chromaticity_texture_id + material["skate3_retail_texture_ids"] = json.dumps( + retail_ids, + sort_keys=True, + separators=(",", ":"), + ) + parameters = _json_property(material, "skate3_retail_parameters") + parameters["skate2_lightmap_component"] = [str(component)] + material["skate3_retail_parameters"] = json.dumps( + parameters, + sort_keys=True, + separators=(",", ":"), + ) + material["skate3_source_lightmap_name"] = source_name + material["skate3_source_chromaticity_name"] = chromaticity_name + material["skate3_lightmap_texture_id"] = texture_id + material["skate3_chromaticity_texture_id"] = chromaticity_texture_id + material["skate2_lightmap_component"] = component + material["ow_lightmap_image"] = image.name + material["ow_lightmap_encoding"] = RETAIL_ENCODING + material["ow_baked_strength"] = 1.0 + + +def main() -> int: + arguments = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else [] + if len(arguments) != 2: + raise SystemExit( + "usage: blender INPUT.blend --background --python " + "repair_skate2_lightmaps.py -- ALIASES_JSON OUTPUT_BLEND" + ) + aliases_path = Path(arguments[0]).resolve() + output = Path(arguments[1]).resolve() + alias_document = json.loads(aliases_path.read_text(encoding="utf-8")) + if alias_document.get("format") != "skate2-lightmap-aliases-v2": + raise ValueError("unsupported Skate 2 lightmap alias document") + aliases = alias_document["aliases"] + if not isinstance(aliases, dict): + raise ValueError("lightmap aliases must be an object") + chromaticity_aliases = alias_document.get("chromaticity_aliases") + if not isinstance(chromaticity_aliases, dict): + raise ValueError("chromaticity aliases must be an object") + mesh_bindings = alias_document.get("mesh_bindings") + if not isinstance(mesh_bindings, dict): + raise ValueError("lightmap aliases have no per-mesh bindings") + cache_root = Path(str(alias_document["cache_root"])).resolve() + + scene = bpy.context.scene + if "Skate 2" not in str( + scene.get("ow_map_name", scene.get("skate3_map_name", scene.name)) + ): + raise RuntimeError("the open scene is not the Skate 2 map") + + source_references = 0 + excluded_shader_materials = 0 + excluded_no_uv_materials = 0 + bound_materials = 0 + bound_images: set[str] = set() + missing_aliases: set[str] = set() + missing_images: set[str] = set() + materials_by_pointer: dict[ + int, tuple[str, str, str, str, int] + ] = {} + source_bindings_by_pointer: dict[int, dict[str, object]] = {} + no_uv_materials: set[int] = set() + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + asset_id = str(obj.get("skate3_asset_id", "")).lower() + mesh_index = obj.get("skate3_mesh_index") + if not asset_id or mesh_index is None: + continue + binding = mesh_bindings.get(f"{asset_id}:{int(mesh_index)}") + if not isinstance(binding, dict): + continue + for material in obj.data.materials: + if material is None: + continue + if bool(binding["eligible"]): + pointer = material.as_pointer() + previous = source_bindings_by_pointer.setdefault( + pointer, binding + ) + if previous != binding: + raise RuntimeError( + f"{material.name!r} maps to conflicting Skate 2 " + "lightmap records" + ) + elif binding.get("exclusion") == "no_secondary_uv": + no_uv_materials.add(material.as_pointer()) + for material in bpy.data.materials: + if "skate3_retail_parameters" not in material: + continue + source_name = _source_lightmap(material) + if not source_name: + continue + source_references += 1 + shader_name = str(material.get("skate3_shader_name", "")).lower() + if shader_name.startswith(("water.", "ocean.")): + excluded_shader_materials += 1 + continue + if material.as_pointer() in no_uv_materials: + excluded_no_uv_materials += 1 + continue + binding = source_bindings_by_pointer.get(material.as_pointer()) + if binding is None: + raise RuntimeError( + f"{material.name!r} has no original Skate 2 mesh binding" + ) + if str(binding["source_name"]) != source_name: + raise RuntimeError( + f"{material.name!r} source lightmap does not match its " + "raw Skate 2 material record" + ) + alias = aliases.get(source_name) + if not isinstance(alias, dict): + missing_aliases.add(source_name) + continue + chromaticity_name = str(binding["chromaticity_name"]) + chromaticity_alias = chromaticity_aliases.get(chromaticity_name) + if not isinstance(chromaticity_alias, dict): + missing_aliases.add(chromaticity_name) + continue + component = int(binding["lightmap_component"]) + if component < 0 or component > 2: + raise RuntimeError( + f"{material.name!r} has invalid Skate 2 lightmap " + f"component {component}" + ) + texture_id = str(alias["texture_id"]) + image = bpy.data.images.get(texture_id) + if image is None: + missing_images.add(texture_id) + continue + expected_size = (int(alias["width"]), int(alias["height"])) + if tuple(image.size) != expected_size: + raise RuntimeError( + f"{texture_id!r} is {tuple(image.size)}, expected " + f"{expected_size}" + ) + image.colorspace_settings.name = "Non-Color" + image["ow_lightmap_encoding"] = RETAIL_ENCODING + image.use_fake_user = True + chromaticity_texture_id = str(chromaticity_alias["texture_id"]) + chromaticity_image = bpy.data.images.get(chromaticity_texture_id) + if chromaticity_image is None: + chromaticity_path = ( + cache_root / str(chromaticity_alias["png"]) + ) + if not chromaticity_path.is_file(): + missing_images.add(chromaticity_texture_id) + continue + chromaticity_image = bpy.data.images.load( + str(chromaticity_path), + check_existing=False, + ) + chromaticity_image.name = chromaticity_texture_id + expected_chromaticity_size = ( + int(chromaticity_alias["width"]), + int(chromaticity_alias["height"]), + ) + if tuple(chromaticity_image.size) != expected_chromaticity_size: + raise RuntimeError( + f"{chromaticity_texture_id!r} is " + f"{tuple(chromaticity_image.size)}, expected " + f"{expected_chromaticity_size}" + ) + chromaticity_image.colorspace_settings.name = "Non-Color" + chromaticity_image["skate2_chromaticity"] = True + chromaticity_image.use_fake_user = True + _bind_material( + material, + source_name, + texture_id, + image, + chromaticity_name, + chromaticity_texture_id, + component, + ) + materials_by_pointer[material.as_pointer()] = ( + source_name, + texture_id, + chromaticity_name, + chromaticity_texture_id, + component, + ) + bound_materials += 1 + bound_images.add(texture_id) + if bound_materials % 5_000 == 0: + print( + f"Bound {bound_materials} Skate 2 lightmapped materials", + flush=True, + ) + + if missing_aliases or missing_images: + raise RuntimeError( + "lightmap repair has unresolved resources: " + f"aliases={sorted(missing_aliases)[:10]}, " + f"images={sorted(missing_images)[:10]}" + ) + if bound_materials == 0 or not bound_images: + raise RuntimeError("lightmap repair produced no usable bindings") + + lightmapped_objects = 0 + lightmapped_loops = 0 + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + object_binding: tuple[str, str, str, str, int] | None = None + for material in obj.data.materials: + if material is None: + continue + binding = materials_by_pointer.get(material.as_pointer()) + if binding is None: + continue + if object_binding is not None and object_binding != binding: + raise RuntimeError( + f"{obj.name!r} uses multiple retail lightmap pages" + ) + object_binding = binding + if object_binding is None: + continue + layer = obj.data.uv_layers.get("Lightmap") + if layer is None or len(layer.data) != len(obj.data.loops): + raise RuntimeError( + f"{obj.name!r} is missing its exact Lightmap UV layer" + ) + ( + source_name, + texture_id, + chromaticity_name, + chromaticity_texture_id, + component, + ) = object_binding + obj["skate3_source_lightmap_name"] = source_name + obj["skate3_source_chromaticity_name"] = chromaticity_name + obj["skate3_source_lightmap_texture_id"] = texture_id + obj["skate3_lightmap_texture_id"] = texture_id + obj["skate3_chromaticity_texture_id"] = chromaticity_texture_id + obj["skate2_lightmap_component"] = component + retail_ids = _json_property( + obj, + "skate3_retail_texture_ids", + ) + retail_ids["lightmap"] = texture_id + retail_ids["chromaticity"] = chromaticity_texture_id + obj["skate3_retail_texture_ids"] = json.dumps( + retail_ids, + sort_keys=True, + separators=(",", ":"), + ) + lightmapped_objects += 1 + lightmapped_loops += len(layer.data) + + if lightmapped_objects != bound_materials: + raise RuntimeError( + f"bound {bound_materials} materials but found " + f"{lightmapped_objects} lightmapped objects" + ) + + scene["skate3_lightmap_status"] = ( + f"{lightmapped_objects} Skate 2 mesh parts use " + f"{len(bound_images)} exact retail layer pages and chromaticity" + ) + scene["skate2_lightmap_aliases"] = str(aliases_path) + scene["skate2_lightmap_repair"] = json.dumps( + { + "source_references": source_references, + "excluded_shader_materials": excluded_shader_materials, + "excluded_no_uv_materials": excluded_no_uv_materials, + "lightmapped_materials": bound_materials, + "lightmapped_objects": lightmapped_objects, + "lightmap_images": len(bound_images), + "chromaticity_images": len( + { + binding[3] + for binding in materials_by_pointer.values() + } + ), + "lightmapped_loops": lightmapped_loops, + }, + sort_keys=True, + ) + output.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile( + filepath=str(output), + check_existing=False, + ) + print( + json.dumps( + { + "output": str(output), + "source_references": source_references, + "excluded_shader_materials": excluded_shader_materials, + "excluded_no_uv_materials": excluded_no_uv_materials, + "lightmapped_materials": bound_materials, + "lightmapped_objects": lightmapped_objects, + "lightmap_images": len(bound_images), + "chromaticity_images": len( + { + binding[3] + for binding in materials_by_pointer.values() + } + ), + "lightmapped_loops": lightmapped_loops, + }, + indent=2, + ), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_lightmaps.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_lightmaps.py new file mode 100644 index 00000000..4f5945d2 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_lightmaps.py @@ -0,0 +1,120 @@ +"""Validate Skate 2 retail lightmaps after reopening the repaired Blender file.""" + +from __future__ import annotations + +from array import array +import json +import sys + +import bpy + + +RETAIL_ENCODING = "skate3_retail_sqrt_linear_over_4" + + +def main() -> int: + lightmapped_materials = 0 + lightmap_images: set[str] = set() + chromaticity_images: set[str] = set() + component_counts = [0, 0, 0] + for material in bpy.data.materials: + image_name = str(material.get("ow_lightmap_image", "")) + if not image_name: + continue + image = bpy.data.images.get(image_name) + if image is None: + raise RuntimeError( + f"{material.name!r} references missing image {image_name!r}" + ) + if ( + str(material.get("ow_lightmap_encoding", "")) + != RETAIL_ENCODING + or float(material.get("ow_baked_strength", 0.0)) != 1.0 + or image.colorspace_settings.name != "Non-Color" + ): + raise RuntimeError( + f"{material.name!r} has an invalid retail lightmap setup" + ) + retail_ids = json.loads( + str(material.get("skate3_retail_texture_ids", "{}")) + ) + if retail_ids.get("lightmap") != image_name: + raise RuntimeError( + f"{material.name!r} has inconsistent lightmap metadata" + ) + chromaticity_name = str(retail_ids.get("chromaticity", "")) + chromaticity = bpy.data.images.get(chromaticity_name) + if ( + not chromaticity_name + or chromaticity is None + or chromaticity.colorspace_settings.name != "Non-Color" + ): + raise RuntimeError( + f"{material.name!r} has invalid chromaticity metadata" + ) + parameters = json.loads( + str(material.get("skate3_retail_parameters", "{}")) + ) + component_values = parameters.get( + "skate2_lightmap_component", [] + ) + if len(component_values) != 1: + raise RuntimeError( + f"{material.name!r} has no Skate 2 lightmap component" + ) + component = int(component_values[0]) + if component < 0 or component > 2: + raise RuntimeError( + f"{material.name!r} has invalid component {component}" + ) + component_counts[component] += 1 + lightmapped_materials += 1 + lightmap_images.add(image_name) + chromaticity_images.add(chromaticity_name) + + lightmapped_objects = 0 + distinct_uv_objects = 0 + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + texture_id = str(obj.get("skate3_lightmap_texture_id", "")) + if not texture_id: + continue + layer = obj.data.uv_layers.get("Lightmap") + base = obj.data.uv_layers.get("UVMap") + if layer is None or len(layer.data) != len(obj.data.loops): + raise RuntimeError(f"{obj.name!r} lost its Lightmap UV layer") + if base is not None: + value_count = len(layer.data) * 2 + light_values = array("f", [0.0]) * value_count + base_values = array("f", [0.0]) * value_count + layer.data.foreach_get("uv", light_values) + base.data.foreach_get("uv", base_values) + distinct_uv_objects += light_values != base_values + lightmapped_objects += 1 + + if lightmapped_materials == 0 or lightmapped_objects == 0: + raise RuntimeError("the repaired file contains no retail lightmaps") + if lightmapped_materials != lightmapped_objects: + raise RuntimeError( + "lightmapped material/object counts differ after reopen" + ) + if not distinct_uv_objects: + raise RuntimeError( + "the repaired file contains no distinct lightmap UVs" + ) + result = { + "lightmapped_materials": lightmapped_materials, + "lightmapped_objects": lightmapped_objects, + "lightmap_images": len(lightmap_images), + "chromaticity_images": len(chromaticity_images), + "lightmap_component_counts": component_counts, + "distinct_lightmap_uv_objects": distinct_uv_objects, + "status": bpy.context.scene.get("skate3_lightmap_status", ""), + } + print(json.dumps(result, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_scene_fix.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_scene_fix.py new file mode 100644 index 00000000..652e61fe --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_skate2_scene_fix.py @@ -0,0 +1,98 @@ +"""Validate the repaired Skate 2 Blender scene after a fresh file reopen.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +import sys + +import bpy + + +SOURCE_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(SOURCE_ROOT / "tools" / "blender_owned_map")) + +from owned_world_material_addon.exporter import _export_retail_grind # noqa: E402 + + +EXPECTED_RAIL_COUNT = 36_058 +EXPECTED_CELL_LOCAL_COUNT = 10_789 +EXPECTED_AMBIGUOUS_COUNT = 97 +SIGN_35_ASSET_ID = "0xd4538d2e1da5434f" + + +def main() -> int: + rails = [ + obj + for obj in bpy.data.objects + if bool(obj.get("skate3_retail_grind", False)) + ] + if len(rails) != EXPECTED_RAIL_COUNT: + raise RuntimeError( + f"expected {EXPECTED_RAIL_COUNT} rails, found {len(rails)}" + ) + + coordinate_counts = { + "cell_local": 0, + "world_space": 0, + "ambiguous": 0, + } + minimum = [math.inf, math.inf, math.inf] + maximum = [-math.inf, -math.inf, -math.inf] + for rail_index, obj in enumerate(rails, 1): + source_frame = str( + obj["skate3_retail_grind_source_coordinate_frame"] + ) + coordinate_counts[source_frame] += 1 + # This is the addon's real exact-native validation path. It compares + # every world-space Bezier control against the export payload. + _export_retail_grind(obj) + for point in obj.data.splines[0].bezier_points: + world = obj.matrix_world @ point.co + for axis in range(3): + minimum[axis] = min(minimum[axis], world[axis]) + maximum[axis] = max(maximum[axis], world[axis]) + if rail_index % 5_000 == 0: + print( + f"Validated {rail_index}/{len(rails)} exportable rails", + flush=True, + ) + + if coordinate_counts["cell_local"] != EXPECTED_CELL_LOCAL_COUNT: + raise RuntimeError( + f"unexpected cell-local count: {coordinate_counts['cell_local']}" + ) + if coordinate_counts["ambiguous"] != EXPECTED_AMBIGUOUS_COUNT: + raise RuntimeError( + f"unexpected ambiguous count: {coordinate_counts['ambiguous']}" + ) + signs = [ + obj.name + for obj in bpy.data.objects + if str(obj.get("skate3_asset_id", "")).lower() == SIGN_35_ASSET_ID + and not bool(obj.get("skate3_retail_grind", False)) + ] + if signs: + raise RuntimeError(f"unplaced Sign_35 objects remain: {signs}") + + result = { + "objects": len(bpy.data.objects), + "meshes": len(bpy.data.meshes), + "images": len(bpy.data.images), + "materials": len(bpy.data.materials), + "grind_rails": len(rails), + "coordinate_frames": coordinate_counts, + "grind_point_bounds": { + "minimum": minimum, + "maximum": maximum, + }, + "sign_35_objects": signs, + "exporter_validation": "passed", + } + print(json.dumps(result, indent=2), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_university_blend.py b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_university_blend.py new file mode 100644 index 00000000..2682a963 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/blender/validate_university_blend.py @@ -0,0 +1,617 @@ +"""Validate University material provenance inside the generated Blender file.""" + +from __future__ import annotations + +from collections import Counter +import json +import struct + +import bpy +from mathutils import Vector +import numpy + + +EXPECTED_MODE_COUNTS = {0: 6389, 1: 2118, 2: 39} +EXPECTED_GRIND_RAILS = 4201 +EXPECTED_GRIND_SEGMENTS = 27008 +EXPECTED_CLOSED_GRIND_RAILS = 372 +EXPECTED_COLLISION_SURFACES = 183 +EXPECTED_COLLISION_TRIANGLES = 1_133_649 +EXPECTED_NORMAL_MAPPED_OBJECTS = 2_987 +EXPECTED_NORMAL_TEXTURES = 135 +EXPECTED_SOURCE_LIGHTMAP_REFERENCES = 8_522 +EXPECTED_LIGHTMAPPED_OBJECTS = 8_489 +EXPECTED_LIGHTMAP_TEXTURES = 1_270 +EXPECTED_LIGHTMAP_EXCLUSIONS = 33 +EXPECTED_RETAIL_WORLD_FRAMES = 3_121 +RETAIL_NORMAL_ATTRIBUTE = ( + "skate3_retail_normal", + "FLOAT_VECTOR", + "vector", + 3, +) +RETAIL_TANGENT_ATTRIBUTES = ( + "skate3_retail_tangent", + "skate3_retail_binormal", +) +RETAIL_HANDEDNESS_ATTRIBUTE = ( + "skate3_retail_tangent_handedness", + "FLOAT", + "value", + 1, +) +REGRESSION_BINDINGS = { + ("0xF6CC7BFCC2C45F8C", 40): ( + "0x861894DE4209CE82", + "0x00800078", + 24, + "0x2c70170a001d00aa", + "0x893793c0de31aab1", + 0, + ), + ("0xF6CC7BFCC2C45F8C", 41): ( + "0xFA839F99082D42ED", + "0x0080007B", + 38, + "0x2c70170a0004000e", + "0xba390198c38936f0", + 0, + ), + ("0xF6CC7BFCC2C45F8C", 42): ( + "0x96879C786774C31D", + "0x0080007E", + 12, + "0x00008d6a03e3870a", + "0xba390198c38936f0", + 0, + ), + ("0x759E349006948F63", 2): ( + "0x861894DE4209CE82", + "0x00800006", + 0, + "0x2c70170a001d00aa", + "0x893793c0de31aab1", + 0, + ), + ("0x759E349006948F63", 3): ( + "0xA0683D15728B6787", + "0x00800009", + 3, + "0x2c70170a00053a88", + "0x893793c0de31aab1", + 1, + ), +} + + +def main() -> int: + objects = [ + obj + for obj in bpy.data.objects + if obj.type == "MESH" and "skate3_asset_id" in obj + ] + if len(objects) != 8546: + raise RuntimeError( + f"University has {len(objects)} imported mesh parts, expected 8546" + ) + + modes: Counter[int] = Counter() + unresolved: Counter[int] = Counter() + normal_mapped_objects = 0 + normal_texture_ids: set[str] = set() + normal_materials: set[int] = set() + source_lightmap_references = 0 + lightmapped_objects = 0 + lightmap_texture_ids: set[str] = set() + lightmap_materials: set[int] = set() + lightmap_exclusions = 0 + retail_world_frames = 0 + lookup: dict[tuple[str, int], bpy.types.Object] = {} + for obj in objects: + alpha_mode = int(obj.get("skate3_alpha_mode", 0)) + texture_id = str(obj.get("skate3_texture_id", "")) + modes[alpha_mode] += 1 + lookup[ + ( + str(obj["skate3_asset_id"]), + int(obj["skate3_mesh_index"]), + ) + ] = obj + source_lightmap_texture_id = str( + obj.get("skate3_source_lightmap_texture_id", "") + ) + lightmap_texture_id = str( + obj.get("skate3_lightmap_texture_id", "") + ) + if source_lightmap_texture_id: + source_lightmap_references += 1 + if not obj.data.materials: + raise RuntimeError( + f"{obj.name!r} lost its retail material definition" + ) + material = obj.data.materials[0] + has_retail_world_frame = bool( + obj.get("skate3_retail_world_frame", False) + ) + tangent_attributes = [ + obj.data.attributes.get(name) + for name in RETAIL_TANGENT_ATTRIBUTES + ] + if sum(attribute is not None for attribute in tangent_attributes) > 1: + raise RuntimeError( + f"{obj.name!r} has both current and legacy retail tangent " + "attributes" + ) + selected_tangent_name = ( + RETAIL_TANGENT_ATTRIBUTES[ + next( + ( + index + for index, attribute in enumerate(tangent_attributes) + if attribute is not None + ), + 0, + ) + ] + ) + frame_schema = ( + RETAIL_NORMAL_ATTRIBUTE, + (selected_tangent_name, "FLOAT_VECTOR", "vector", 3), + RETAIL_HANDEDNESS_ATTRIBUTE, + ) + frame_attributes = [] + for ( + attribute_name, + data_type, + _property_name, + _components, + ) in frame_schema: + attribute = obj.data.attributes.get(attribute_name) + if has_retail_world_frame: + if ( + attribute is None + or attribute.domain != "POINT" + or attribute.data_type != data_type + or len(attribute.data) != len(obj.data.vertices) + ): + raise RuntimeError( + f"{obj.name!r} has invalid retail world-frame " + f"attribute {attribute_name!r}" + ) + frame_attributes.append(attribute) + elif attribute is not None: + raise RuntimeError( + f"{obj.name!r} has unexpected retail world-frame " + f"attribute {attribute_name!r}" + ) + if has_retail_world_frame: + decoded = [] + for attribute, ( + _attribute_name, + _data_type, + property_name, + components, + ) in zip( + frame_attributes, + frame_schema, + strict=True, + ): + values = numpy.empty( + len(attribute.data) * components, + dtype=numpy.float32, + ) + attribute.data.foreach_get(property_name, values) + if not numpy.isfinite(values).all(): + raise RuntimeError( + f"{obj.name!r} retail world frame is non-finite" + ) + decoded.append(values.reshape((-1, components))) + normal_lengths = numpy.linalg.norm(decoded[0], axis=1) + if ( + normal_lengths.size + and ( + float(normal_lengths.min()) < 0.999 + or float(normal_lengths.max()) > 1.001 + ) + ): + raise RuntimeError( + f"{obj.name!r} retail normals are not unit length" + ) + if not numpy.all(numpy.abs(decoded[2][:, 0]) > 0.999): + raise RuntimeError( + f"{obj.name!r} retail tangent handedness is invalid" + ) + retail_world_frames += 1 + expected_material_texture_id = texture_id or "NO_DIFFUSE" + if ( + str(material.get("skate3_texture_id", "")) + != expected_material_texture_id + or int(material.get("skate3_alpha_mode", -1)) != alpha_mode + ): + raise RuntimeError( + f"{obj.name!r} material provenance does not match the mesh" + ) + if "skate3_fallback_reason" in material: + unresolved[alpha_mode] += 1 + if lightmap_texture_id: + if lightmap_texture_id != source_lightmap_texture_id: + raise RuntimeError( + f"{obj.name!r} substituted retail lightmap " + f"{source_lightmap_texture_id!r} with " + f"{lightmap_texture_id!r}" + ) + lightmap_layer = obj.data.uv_layers.get("Lightmap") + if ( + lightmap_layer is None + or len(lightmap_layer.data) != len(obj.data.loops) + ): + raise RuntimeError( + f"{obj.name!r} lost its retail lightmap UVs" + ) + lightmap_image = bpy.data.images.get(lightmap_texture_id) + if lightmap_image is None: + raise RuntimeError( + f"{obj.name!r} is missing lightmap image " + f"{lightmap_texture_id!r}" + ) + if lightmap_image.colorspace_settings.name != "Non-Color": + raise RuntimeError( + f"{lightmap_texture_id!r} is not marked Non-Color" + ) + if ( + str(material.get("skate3_lightmap_texture_id", "")) + != lightmap_texture_id + or str(material.get("ow_lightmap_image", "")) + != lightmap_image.name + or str(material.get("ow_lightmap_encoding", "")) + != "skate3_retail_sqrt_linear_over_4" + or float(material.get("ow_baked_strength", 0.0)) != 1.0 + ): + raise RuntimeError( + f"{material.name!r} does not export its retail lightmap" + ) + lightmapped_objects += 1 + lightmap_texture_ids.add(lightmap_texture_id) + lightmap_materials.add(material.as_pointer()) + elif source_lightmap_texture_id: + reason = str(obj.get("skate3_lightmap_exclusion", "")) + if not reason: + raise RuntimeError( + f"{obj.name!r} silently discarded retail lightmap " + f"{source_lightmap_texture_id!r}" + ) + if ( + str(material.get("skate3_lightmap_texture_id", "")) + or str(material.get("ow_lightmap_image", "")) + ): + raise RuntimeError( + f"{obj.name!r} inherited a lightmap despite exclusion" + ) + lightmap_exclusions += 1 + normal_texture_id = str( + obj.get("skate3_normal_texture_id", "") + ) + source_normal_texture_id = str( + obj.get("skate3_source_normal_texture_id", "") + ) + if normal_texture_id: + if normal_texture_id != source_normal_texture_id: + raise RuntimeError( + f"{obj.name!r} substituted retail normal " + f"{source_normal_texture_id!r} with " + f"{normal_texture_id!r}" + ) + if ( + str(material.get("skate3_normal_texture_id", "")) + != normal_texture_id + ): + raise RuntimeError( + f"{obj.name!r} material lost normal provenance" + ) + normal_image = bpy.data.images.get(normal_texture_id) + if normal_image is None: + raise RuntimeError( + f"{obj.name!r} is missing normal image " + f"{normal_texture_id!r}" + ) + if normal_image.colorspace_settings.name != "Non-Color": + raise RuntimeError( + f"{normal_texture_id!r} is not marked Non-Color" + ) + if ( + str(material.get("ow_normal_image", "")) + != normal_image.name + ): + raise RuntimeError( + f"{material.name!r} does not export its retail normal" + ) + normal_mapped_objects += 1 + normal_texture_ids.add(normal_texture_id) + normal_materials.add(material.as_pointer()) + elif ( + str(material.get("skate3_normal_texture_id", "")) + or str(material.get("ow_normal_image", "")) + ): + raise RuntimeError( + f"{obj.name!r} unexpectedly inherited another mesh's normal" + ) + + if dict(modes) != EXPECTED_MODE_COUNTS: + raise RuntimeError( + f"University alpha modes changed: {dict(modes)}" + ) + if normal_mapped_objects != EXPECTED_NORMAL_MAPPED_OBJECTS: + raise RuntimeError( + f"University has {normal_mapped_objects} normal-mapped mesh " + f"parts, expected {EXPECTED_NORMAL_MAPPED_OBJECTS}" + ) + if len(normal_texture_ids) != EXPECTED_NORMAL_TEXTURES: + raise RuntimeError( + f"University has {len(normal_texture_ids)} conventional retail " + f"normal textures, expected {EXPECTED_NORMAL_TEXTURES}" + ) + if source_lightmap_references != EXPECTED_SOURCE_LIGHTMAP_REFERENCES: + raise RuntimeError( + f"University has {source_lightmap_references} source lightmap " + f"references, expected {EXPECTED_SOURCE_LIGHTMAP_REFERENCES}" + ) + if lightmapped_objects != EXPECTED_LIGHTMAPPED_OBJECTS: + raise RuntimeError( + f"University has {lightmapped_objects} lightmapped mesh parts, " + f"expected {EXPECTED_LIGHTMAPPED_OBJECTS}" + ) + if len(lightmap_texture_ids) != EXPECTED_LIGHTMAP_TEXTURES: + raise RuntimeError( + f"University has {len(lightmap_texture_ids)} exported retail " + f"lightmaps, expected {EXPECTED_LIGHTMAP_TEXTURES}" + ) + if lightmap_exclusions != EXPECTED_LIGHTMAP_EXCLUSIONS: + raise RuntimeError( + f"University has {lightmap_exclusions} explicit lightmap " + f"exclusions, expected {EXPECTED_LIGHTMAP_EXCLUSIONS}" + ) + if retail_world_frames != EXPECTED_RETAIL_WORLD_FRAMES: + raise RuntimeError( + f"University has {retail_world_frames} exact retail world " + f"frames, expected {EXPECTED_RETAIL_WORLD_FRAMES}" + ) + + for key, expected in REGRESSION_BINDINGS.items(): + obj = lookup.get(key) + if obj is None: + raise RuntimeError(f"regression mesh {key!r} is missing") + actual = ( + str(obj.get("skate3_retail_material_guid", "")), + str(obj.get("skate3_retail_material_handle", "")), + int(obj.get("skate3_retail_material_group_index", -1)), + str(obj.get("skate3_texture_id", "")), + str(obj.get("skate3_lightmap_texture_id", "")), + int(obj.get("skate3_alpha_mode", 0)), + ) + if actual != expected: + raise RuntimeError( + f"regression mesh {key!r}: {actual!r} != {expected!r}" + ) + + grind_objects = [ + obj + for obj in bpy.data.objects + if obj.type == "CURVE" + and bool(obj.get("skate3_retail_grind", False)) + ] + if len(grind_objects) != EXPECTED_GRIND_RAILS: + raise RuntimeError( + f"University has {len(grind_objects)} retail grind rails, " + f"expected {EXPECTED_GRIND_RAILS}" + ) + grind_segments = 0 + closed_grinds = 0 + for obj in grind_objects: + if len(obj.data.splines) != 1: + raise RuntimeError( + f"{obj.name!r} does not contain exactly one retail spline" + ) + spline = obj.data.splines[0] + if spline.type != "BEZIER": + raise RuntimeError(f"{obj.name!r} is not a Bezier spline") + segment_count = int( + obj["skate3_retail_grind_segment_count"] + ) + actual_segment_count = ( + len(spline.bezier_points) + if spline.use_cyclic_u + else len(spline.bezier_points) - 1 + ) + if actual_segment_count != segment_count: + raise RuntimeError( + f"{obj.name!r} has {actual_segment_count} Blender segments " + f"but {segment_count} retail segments" + ) + payload = bytes.fromhex( + str(obj["skate3_retail_grind_segment_payload"]) + ) + if len(payload) != segment_count * 120: + raise RuntimeError( + f"{obj.name!r} has an invalid native segment payload" + ) + if int( + str(obj["skate3_retail_grind_spline_id"]), + 16, + ) == 0 or int( + str(obj["skate3_retail_grind_type_signature"]), + 16, + ) == 0: + raise RuntimeError( + f"{obj.name!r} lost its retail spline identity" + ) + + points = spline.bezier_points + for segment_index in range(segment_count): + values = struct.unpack_from( + ">30f", + payload, + segment_index * 120, + ) + coefficient_a = values[0:3] + coefficient_b = values[4:7] + coefficient_c = values[8:11] + coefficient_d = values[12:15] + runtime_controls = ( + coefficient_d, + tuple( + coefficient_d[axis] + coefficient_c[axis] / 3.0 + for axis in range(3) + ), + tuple( + coefficient_d[axis] + + ( + 2.0 * coefficient_c[axis] + + coefficient_b[axis] + ) + / 3.0 + for axis in range(3) + ), + tuple( + coefficient_d[axis] + + coefficient_c[axis] + + coefficient_b[axis] + + coefficient_a[axis] + for axis in range(3) + ), + ) + expected_controls = tuple( + Vector((point[0], -point[2], point[1])) + for point in runtime_controls + ) + current = points[segment_index] + following = points[ + (segment_index + 1) % len(points) + ] + actual_controls = ( + obj.matrix_world @ current.co, + obj.matrix_world @ current.handle_right, + obj.matrix_world @ following.handle_left, + obj.matrix_world @ following.co, + ) + control_errors = [ + (actual - expected).length + for actual, expected in zip( + actual_controls, + expected_controls, + ) + ] + # Blender stores curve points as float32. Retail cubic endpoints + # are independently evaluated coefficients, so cancellation at + # University-scale coordinates can add sub-millimetre rounding. + if any(error > 2.0e-3 for error in control_errors): + raise RuntimeError( + f"{obj.name!r} segment {segment_index} no longer " + "matches its exact retail cubic: " + f"control errors={control_errors}" + ) + grind_segments += segment_count + closed_grinds += bool(spline.use_cyclic_u) + + if ( + grind_segments != EXPECTED_GRIND_SEGMENTS + or closed_grinds != EXPECTED_CLOSED_GRIND_RAILS + ): + raise RuntimeError( + "University retail grind totals changed: " + f"segments={grind_segments}, closed={closed_grinds}" + ) + + collision_objects = [ + obj + for obj in bpy.data.objects + if obj.type == "MESH" + and bool(obj.get("skate3_retail_collision", False)) + ] + if len(collision_objects) != EXPECTED_COLLISION_SURFACES: + raise RuntimeError( + f"University has {len(collision_objects)} retail collision " + f"surfaces, expected {EXPECTED_COLLISION_SURFACES}" + ) + collision_triangles = 0 + for obj in collision_objects: + if len(obj.data.materials) != 1 or obj.data.materials[0] is None: + raise RuntimeError( + f"{obj.name!r} does not have one retail collision material" + ) + surface = int(str(obj["skate3_retail_surface_id"]), 16) + if not bool( + obj.get("ow_preserve_opposite_wound_collision", False) + ): + raise RuntimeError( + f"{obj.name!r} would discard reverse-wound retail collision" + ) + if not bool(obj.get("ow_preserve_retail_edge_codes", False)): + raise RuntimeError( + f"{obj.name!r} would discard native retail edge codes" + ) + for corner in range(3): + attribute_name = f"skate3_retail_edge_code_{corner}" + attribute = obj.data.attributes.get(attribute_name) + if ( + attribute is None + or attribute.domain != "FACE" + or attribute.data_type != "INT" + or len(attribute.data) != len(obj.data.polygons) + ): + raise RuntimeError( + f"{obj.name!r} has invalid {attribute_name!r} metadata" + ) + material = obj.data.materials[0] + encoded = ( + int(material["ow_audio_surface"]) + | (int(material["ow_physics_surface"]) << 7) + | (int(material["ow_surface_pattern"]) << 12) + ) + if encoded != surface: + raise RuntimeError( + f"{obj.name!r} packed surface changed: " + f"0x{encoded:04X} != 0x{surface:04X}" + ) + triangle_count = len(obj.data.polygons) + if triangle_count != int(obj["skate3_retail_triangle_count"]): + raise RuntimeError( + f"{obj.name!r} retail triangle count changed" + ) + collision_triangles += triangle_count + if collision_triangles != EXPECTED_COLLISION_TRIANGLES: + raise RuntimeError( + f"University has {collision_triangles} retail collision triangles, " + f"expected {EXPECTED_COLLISION_TRIANGLES}" + ) + + print( + json.dumps( + { + "status": "UNIVERSITY_BLEND_MATERIALS_OK", + "mesh_parts": len(objects), + "alpha_modes": dict(sorted(modes.items())), + "unresolved_by_alpha_mode": dict(sorted(unresolved.items())), + "regression_bindings": len(REGRESSION_BINDINGS), + "normal_mapped_objects": normal_mapped_objects, + "normal_textures": len(normal_texture_ids), + "normal_materials": len(normal_materials), + "source_lightmap_references": source_lightmap_references, + "lightmapped_objects": lightmapped_objects, + "lightmap_textures": len(lightmap_texture_ids), + "lightmap_materials": len(lightmap_materials), + "lightmap_exclusions": lightmap_exclusions, + "retail_world_frames": retail_world_frames, + "grind_rails": len(grind_objects), + "grind_segments": grind_segments, + "closed_grind_rails": closed_grinds, + "collision_surfaces": len(collision_objects), + "collision_triangles": collision_triangles, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_retail_world_frames.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_retail_world_frames.py new file mode 100644 index 00000000..b290a50b --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_retail_world_frames.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Compare preserved retail world-frame vectors with geometry-derived axes.""" + +from __future__ import annotations + +import argparse +import json +import math +from collections import defaultdict +from pathlib import Path + +import numpy + + +def _normalize(values: numpy.ndarray) -> numpy.ndarray: + lengths = numpy.linalg.norm(values, axis=1) + result = numpy.zeros_like(values, dtype=numpy.float64) + valid = lengths > 1.0e-8 + result[valid] = values[valid] / lengths[valid, None] + return result + + +def _triangle_axes( + positions: numpy.ndarray, + uvs: numpy.ndarray, + faces: numpy.ndarray, +) -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]: + p0 = positions[faces[:, 0]] + p1 = positions[faces[:, 1]] + p2 = positions[faces[:, 2]] + uv0 = uvs[faces[:, 0]] + uv1 = uvs[faces[:, 1]] + uv2 = uvs[faces[:, 2]] + edge1 = p1 - p0 + edge2 = p2 - p0 + delta1 = uv1 - uv0 + delta2 = uv2 - uv0 + determinant = delta1[:, 0] * delta2[:, 1] - delta1[:, 1] * delta2[:, 0] + valid = ( + numpy.isfinite(determinant) + & (numpy.abs(determinant) > 1.0e-8) + & (numpy.linalg.norm(numpy.cross(edge1, edge2), axis=1) > 1.0e-8) + ) + inverse = numpy.zeros_like(determinant, dtype=numpy.float64) + inverse[valid] = 1.0 / determinant[valid] + tangent = ( + edge1 * delta2[:, 1, None] - edge2 * delta1[:, 1, None] + ) * inverse[:, None] + binormal = ( + edge2 * delta1[:, 0, None] - edge1 * delta2[:, 0, None] + ) * inverse[:, None] + return _normalize(tangent), _normalize(binormal), valid + + +def _mesh_measurements( + arrays: numpy.lib.npyio.NpzFile, + mesh_index: int, +) -> tuple[numpy.ndarray, numpy.ndarray]: + positions = numpy.asarray( + arrays[f"vertices_{mesh_index}"], dtype=numpy.float64 + ) + uvs = numpy.asarray(arrays[f"uvs_{mesh_index}"], dtype=numpy.float64) + faces = numpy.asarray(arrays[f"faces_{mesh_index}"], dtype=numpy.int64) + source = _normalize( + numpy.asarray( + arrays[ + f"retail_tangents_{mesh_index}" + if f"retail_tangents_{mesh_index}" in arrays + else f"retail_binormals_{mesh_index}" + ], + dtype=numpy.float64, + ) + ) + normals = _normalize( + numpy.asarray( + arrays[f"retail_normals_{mesh_index}"], dtype=numpy.float64 + ) + ) + handedness = numpy.asarray( + arrays[f"retail_tangent_handedness_{mesh_index}"], + dtype=numpy.float64, + ) + tangents, binormals, valid = _triangle_axes(positions, uvs, faces) + if not numpy.any(valid): + return numpy.empty((0, 5)), numpy.empty((0,), dtype=numpy.int64) + + corners = faces[valid].reshape(-1) + triangle_tangents = numpy.repeat(tangents[valid], 3, axis=0) + triangle_binormals = numpy.repeat(binormals[valid], 3, axis=0) + source = source[corners] + normals = normals[corners] + handedness = handedness[corners] + + source = _normalize( + source - normals * numpy.sum(source * normals, axis=1)[:, None] + ) + triangle_tangents = _normalize( + triangle_tangents + - normals * numpy.sum(triangle_tangents * normals, axis=1)[:, None] + ) + triangle_binormals = _normalize( + triangle_binormals + - normals * numpy.sum(triangle_binormals * normals, axis=1)[:, None] + ) + + tangent_dot = numpy.sum(source * triangle_tangents, axis=1) + binormal_dot = numpy.sum(source * triangle_binormals, axis=1) + reconstructed_binormal = _normalize( + numpy.cross(normals, source) * handedness[:, None] + ) + reconstructed_dot = numpy.sum( + reconstructed_binormal * triangle_binormals, axis=1 + ) + measurements = numpy.column_stack( + ( + numpy.abs(tangent_dot), + numpy.abs(binormal_dot), + tangent_dot, + binormal_dot, + reconstructed_dot, + ) + ) + finite = numpy.isfinite(measurements).all(axis=1) + return measurements[finite], corners[finite] + + +def _summary(rows: list[numpy.ndarray]) -> dict[str, float | int]: + values = numpy.concatenate(rows, axis=0) + tangent_abs = values[:, 0] + binormal_abs = values[:, 1] + return { + "corners": int(len(values)), + "mean_abs_dot_tangent": float(numpy.mean(tangent_abs)), + "mean_abs_dot_binormal": float(numpy.mean(binormal_abs)), + "median_abs_dot_tangent": float(numpy.median(tangent_abs)), + "median_abs_dot_binormal": float(numpy.median(binormal_abs)), + "tangent_wins_percent": float( + numpy.mean(tangent_abs > binormal_abs) * 100.0 + ), + "binormal_wins_percent": float( + numpy.mean(binormal_abs > tangent_abs) * 100.0 + ), + "mean_signed_dot_tangent": float(numpy.mean(values[:, 2])), + "mean_signed_dot_binormal": float(numpy.mean(values[:, 3])), + "mean_reconstructed_binormal_dot": float(numpy.mean(values[:, 4])), + } + + +def analyze(root: Path) -> dict[str, object]: + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + groups: dict[str, list[numpy.ndarray]] = defaultdict(list) + mesh_count = 0 + model_count = 0 + for model in manifest["models"]: + relevant = [ + mesh + for mesh in model["meshes"] + if mesh.get("retail_world_frame") + ] + if not relevant: + continue + model_count += 1 + asset_id = int(model["asset_id"], 0) + with numpy.load(root / "models" / f"{asset_id:016X}.npz") as arrays: + for mesh in relevant: + index = int(mesh["index"]) + tangent_keys = ( + f"retail_tangents_{index}", + f"retail_binormals_{index}", + ) + if ( + not any(key in arrays for key in tangent_keys) + or f"uvs_{index}" not in arrays + ): + continue + measurements, _ = _mesh_measurements(arrays, index) + if len(measurements) == 0: + continue + mesh_count += 1 + groups["all"].append(measurements) + groups[f"shader:{mesh.get('shader_name', '')}"].append( + measurements + ) + world_frame = mesh["retail_world_frame"] + frame_offset = world_frame.get( + "tangent_offset", + world_frame.get("binormal_offset", -1), + ) + layout = ( + f"layout:{mesh.get('vertex_stride', 0)}/{frame_offset}" + ) + groups[layout].append(measurements) + summaries = { + key: _summary(rows) + for key, rows in sorted(groups.items()) + if rows + } + return { + "source": str(root), + "models": model_count, + "meshes": mesh_count, + "groups": summaries, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "intermediate", + type=Path, + help="Prepared map intermediate directory containing manifest.json", + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + result = analyze(args.intermediate.resolve()) + if args.json: + print(json.dumps(result, indent=2)) + return 0 + print(f"models={result['models']} meshes={result['meshes']}") + for key, values in result["groups"].items(): + print( + f"{key}: corners={values['corners']} " + f"|dot(T)|={values['mean_abs_dot_tangent']:.4f} " + f"|dot(B)|={values['mean_abs_dot_binormal']:.4f} " + f"T-wins={values['tangent_wins_percent']:.2f}% " + f"B-wins={values['binormal_wins_percent']:.2f}% " + f"reconstructed-B={values['mean_reconstructed_binormal_dot']:.4f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_skate2_lightmap_basis.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_skate2_lightmap_basis.py new file mode 100644 index 00000000..bb616d0c --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/analyze_skate2_lightmap_basis.py @@ -0,0 +1,118 @@ +"""Correlate Skate 2 layer-page channels with extracted surface normals.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import numpy +from PIL import Image + + +def _sample_vertices( + pixels: numpy.ndarray, + uvs: numpy.ndarray, +) -> numpy.ndarray: + height, width, _channels = pixels.shape + coordinates = numpy.abs(uvs) + xs = numpy.rint(coordinates[:, 0] * (width - 1)).astype(numpy.int32) + ys = numpy.rint((1.0 - coordinates[:, 1]) * (height - 1)).astype( + numpy.int32 + ) + xs = numpy.clip(xs, 0, width - 1) + ys = numpy.clip(ys, 0, height - 1) + samples = [] + for x, y in zip(xs, ys, strict=True): + x0 = max(0, x - 2) + x1 = min(width, x + 3) + y0 = max(0, y - 2) + y1 = min(height, y + 3) + samples.append(pixels[y0:y1, x0:x1].max(axis=(0, 1))) + return numpy.asarray(samples, dtype=numpy.float32) + + +def analyze(cache_root: Path, limit: int) -> dict[str, object]: + manifest = json.loads( + (cache_root / "manifest.json").read_text(encoding="utf-8") + ) + aliases = json.loads( + (cache_root / "lightmap_aliases.json").read_text(encoding="utf-8") + )["aliases"] + confusion = numpy.zeros((3, 3), dtype=numpy.int64) + weighted = numpy.zeros((3, 3), dtype=numpy.float64) + accepted = 0 + image_cache: dict[str, numpy.ndarray] = {} + + for model in manifest["models"]: + if accepted >= limit: + break + npz_path = ( + cache_root + / "models" + / f"{str(model['asset_id'])[2:]}.npz" + ) + if not npz_path.is_file(): + continue + with numpy.load(npz_path) as arrays: + for mesh in model["meshes"]: + if accepted >= limit: + break + index = int(mesh["index"]) + normal_key = f"normals_{index}" + uv_key = f"lightmap_uvs_{index}" + if normal_key not in arrays or uv_key not in arrays: + continue + values = mesh.get("retail_parameters", {}).get( + "lightmap", [] + ) + if not values or not values[0]: + continue + alias = aliases.get(str(values[0])) + if alias is None: + continue + image_name = str(alias["png"]) + pixels = image_cache.get(image_name) + if pixels is None: + pixels = numpy.asarray( + Image.open(cache_root / image_name).convert("RGB"), + dtype=numpy.float32, + ) + image_cache[image_name] = pixels + samples = _sample_vertices(pixels, arrays[uv_key]) + mean_sample = samples.mean(axis=0) + if float(mean_sample.max()) <= 4.0: + continue + mean_normal = numpy.abs(arrays[normal_key]).mean(axis=0) + normal_axis = int(numpy.argmax(mean_normal)) + channel = int(numpy.argmax(mean_sample)) + confusion[normal_axis, channel] += 1 + weighted[normal_axis] += mean_sample + accepted += 1 + + return { + "accepted_meshes": accepted, + "confusion_normal_axis_by_layer_channel": confusion.tolist(), + "mean_layer_rgb_by_normal_axis": ( + weighted + / numpy.maximum(confusion.sum(axis=1), 1)[:, numpy.newaxis] + ).round(3).tolist(), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("cache_root", type=Path) + parser.add_argument("--limit", type=int, default=5000) + args = parser.parse_args() + print( + json.dumps( + analyze(args.cache_root.resolve(), max(1, args.limit)), + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/attach_retail_collision_identity.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/attach_retail_collision_identity.py new file mode 100644 index 00000000..d88ea874 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/attach_retail_collision_identity.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Attach exact-retail collision provenance to an existing SKATE v12 map. + +RCID v1 maps each portable collision triangle back to every untouched +ClusteredMesh resource/cluster that supplied it. This allows the runtime to +stream exact retail collision until an editor object moves, then replace only +the affected resource without leaving the object's original hitbox behind. +""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import struct +import sys +import zlib + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import analyze_package # noqa: E402 +from retail_collision_mesh import decode_clustered_mesh # noqa: E402 + + +ARCHIVE_MAGIC = b"RWCMSET1" +EXTENSION_TAG = b"RCID" +EXTENSION_SCHEMA = 1 +COLLISION_RECORD = struct.Struct("<9fII4B") +ASSOCIATION_RECORD = struct.Struct(" tuple[int, ...]: + rotations = tuple( + ( + points[offset:] + points[:offset], + edge_codes[offset:] + edge_codes[:offset], + ) + for offset in range(3) + ) + rotated_points, rotated_codes = min( + rotations, + key=lambda item: tuple( + round(component * 1_000_000.0) + for point in item[0] + for component in point + ), + ) + return ( + surface, + *( + round(component * 1_000_000.0) + for point in rotated_points + for component in point + ), + *rotated_codes, + ) + + +def _read_archive( + path: Path, +) -> list[tuple[str, bytes]]: + data = path.read_bytes() + if len(data) < 12 or data[:8] != ARCHIVE_MAGIC: + raise ValueError("retail collision archive magic is invalid") + count = struct.unpack_from(" len(data): + raise ValueError(f"archive record {index} name is truncated") + name_size = struct.unpack_from(" len(data): + raise ValueError(f"archive record {index} name is invalid") + name = data[cursor : cursor + name_size].decode("utf-8") + cursor += name_size + mesh_size = struct.unpack_from(" len(data): + raise ValueError(f"archive record {index} mesh is truncated") + records.append((name, data[cursor : cursor + mesh_size])) + cursor += mesh_size + if cursor != len(data): + raise ValueError("retail collision archive has trailing bytes") + return records + + +def _stored(payload: bytes) -> bytes: + compressed = zlib.compress(payload, level=9) + if len(compressed) < len(payload): + return struct.pack(" dict[str, object]: + analysis = analyze_package(package_path, include_payloads=True) + if int(analysis["version"]) != 12: + raise ValueError("RCID requires a SKATE v12 package") + if EXTENSION_TAG.decode("ascii") in analysis["extension_tags"]: + raise ValueError("SKATE package already contains RCID") + + materials = { + int(material["id"]): material + for material in analysis["_materials"] + } + collision = analysis["_collision_bytes"] + if len(collision) % COLLISION_RECORD.size: + raise ValueError("SKATE collision block has an invalid size") + triangle_count = len(collision) // COLLISION_RECORD.size + + package_lookup: dict[tuple[int, ...], int] = {} + for triangle_index in range(triangle_count): + values = COLLISION_RECORD.unpack_from( + collision, triangle_index * COLLISION_RECORD.size + ) + material = materials.get(int(values[10])) + if material is None: + raise ValueError( + f"collision triangle {triangle_index} has no material" + ) + surface = ( + int(material["audio_surface"]) + | (int(material["physics_surface"]) << 7) + | (int(material["surface_pattern"]) << 12) + ) + if values[14] != 1: + raise ValueError( + f"collision triangle {triangle_index} omits edge codes" + ) + key = _canonical_key( + ( + tuple(values[0:3]), + tuple(values[3:6]), + tuple(values[6:9]), + ), + surface, + tuple(values[11:14]), + ) + previous = package_lookup.setdefault(key, triangle_index) + if previous != triangle_index: + raise ValueError( + "SKATE collision contains an ambiguous duplicate at " + f"triangles {previous} and {triangle_index}" + ) + + archive_records = _read_archive(archive_path) + if len(archive_records) > 0xFFFF: + raise ValueError("retail collision archive has too many resources") + associations: list[tuple[int, int, int, int, int]] = [] + coverage = bytearray(triangle_count) + unmatched_exact = 0 + for resource_index, (name, mesh_bytes) in enumerate(archive_records): + mesh = decode_clustered_mesh(mesh_bytes) + if mesh.cluster_count > 0xFFFF: + raise ValueError(f"{name} has too many collision clusters") + for source_index, triangle in enumerate(mesh.triangles): + if triangle.edge_codes is None: + raise ValueError(f"{name} omits retail edge codes") + key = _canonical_key( + (triangle.a, triangle.b, triangle.c), + triangle.surface, + triangle.edge_codes, + ) + triangle_index = package_lookup.get(key) + if triangle_index is None: + unmatched_exact += 1 + continue + cluster_index = mesh.triangle_cluster_indices[source_index] + associations.append( + ( + triangle_index, + resource_index, + cluster_index, + ( + triangle.group_id + if triangle.group_id is not None + else 0xFFFFFFFF + ), + triangle.unit_flags, + ) + ) + coverage[triangle_index] = 1 + + missing = coverage.count(0) + if missing: + first = coverage.index(0) + raise ValueError( + "retail collision archive does not cover every package triangle: " + f"missing={missing} first={first}" + ) + associations = sorted(set(associations)) + + payload = bytearray() + payload.extend(struct.pack(" len(package_bytes): + raise ValueError("SKATE extension table location is invalid") + extension_count = struct.unpack_from( + " int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument("archive", type=Path) + parser.add_argument("output", type=Path) + arguments = parser.parse_args() + result = attach_identity( + arguments.package.resolve(), + arguments.archive.resolve(), + arguments.output.resolve(), + ) + import json + + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/audit_university_collision.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/audit_university_collision.py new file mode 100644 index 00000000..2f9a3c91 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/audit_university_collision.py @@ -0,0 +1,106 @@ +"""Decode and audit every retail University ClusteredMesh collision section.""" + +from __future__ import annotations + +from collections import Counter +import argparse +import hashlib +import json +from pathlib import Path + +from retail_collision_mesh import decode_rx2_clustered_meshes + + +def main() -> int: + workspace = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=workspace / "intermediate" / "university" / "manifest.json", + ) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + manifest_path = args.manifest.resolve() + cache_root = manifest_path.parent + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assets = manifest["simulation_assets"] + mesh_count = 0 + cluster_count = 0 + vertex_count = 0 + unit_count = 0 + triangle_count = 0 + source_bytes = 0 + surfaces: Counter[int] = Counter() + compressions: Counter[int] = Counter() + bounds_min = [float("inf")] * 3 + bounds_max = [float("-inf")] * 3 + source_digest = hashlib.sha256() + mesh_assets: list[str] = [] + + for asset in assets: + path = cache_root / Path(str(asset["rx2"]).replace("\\", "/")) + data = path.read_bytes() + try: + meshes = decode_rx2_clustered_meshes(data) + except ValueError as error: + raise ValueError( + f"{asset['asset_id']} ({path.name}): {error}" + ) from error + if not meshes: + continue + source_digest.update(bytes.fromhex(str(asset["asset_id"])[2:])) + source_digest.update(hashlib.sha256(data).digest()) + source_bytes += len(data) + mesh_assets.append(str(asset["asset_id"])) + for mesh in meshes: + mesh_count += 1 + cluster_count += mesh.cluster_count + vertex_count += mesh.vertex_count + unit_count += mesh.unit_count + triangle_count += len(mesh.triangles) + for compression, count in mesh.compression_counts: + compressions[compression] += count + for triangle in mesh.triangles: + surfaces[triangle.surface] += 1 + for axis in range(3): + bounds_min[axis] = min(bounds_min[axis], mesh.bounds_min[axis]) + bounds_max[axis] = max(bounds_max[axis], mesh.bounds_max[axis]) + + result = { + "format": "skate3-retail-collision-audit-v1", + "district": manifest["district_name"], + "simulation_assets": len(assets), + "clustered_mesh_assets": len(mesh_assets), + "clustered_mesh_sections": mesh_count, + "clusters": cluster_count, + "cluster_vertices": vertex_count, + "units": unit_count, + "triangles": triangle_count, + "surfaces": len(surfaces), + "surface_triangle_counts": { + f"0x{surface:04X}": count + for surface, count in sorted(surfaces.items()) + }, + "compression_cluster_counts": { + str(compression): count + for compression, count in sorted(compressions.items()) + }, + "bounds_min": bounds_min, + "bounds_max": bounds_max, + "source_rx2_bytes": source_bytes, + "source_set_sha256": source_digest.hexdigest(), + "asset_ids": mesh_assets, + } + encoded = json.dumps(result, indent=2) + "\n" + if args.output is not None: + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(encoded, encoding="utf-8") + print(encoded, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/build_retail_collision_archive.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_retail_collision_archive.py new file mode 100644 index 00000000..d0da15e1 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_retail_collision_archive.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Package untouched retail RenderWare collision meshes beside a SKATE map.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct + + +RX2_COLLISION_TYPE = 0x00080006 +MAGIC = b"RWCMSET1" + + +def _be_u32(data: bytes, offset: int) -> int: + return struct.unpack_from(">I", data, offset)[0] + + +def collision_sections(rx2: bytes) -> list[bytes]: + """Return untouched ClusteredMesh sections from one Xbox 360 RW4 RX2.""" + + if len(rx2) < 0x34 or rx2[:7] != b"\x89RW4xb2": + raise ValueError("input is not an Xbox 360 RW4 RX2 resource") + count = _be_u32(rx2, 0x20) + table = _be_u32(rx2, 0x30) + if table + count * 24 > len(rx2): + raise ValueError("RX2 section table extends beyond the resource") + result: list[bytes] = [] + for index in range(count): + record = table + index * 24 + offset = _be_u32(rx2, record) + size = _be_u32(rx2, record + 8) + section_type = _be_u32(rx2, record + 20) + if section_type != RX2_COLLISION_TYPE: + continue + if offset + size > len(rx2): + raise ValueError("RX2 collision section extends beyond the resource") + result.append(rx2[offset : offset + size]) + return result + + +def build_archive( + manifest_path: Path, + output_path: Path, + *, + expected_asset_count: int = 0, + expected_mesh_count: int = 0, +) -> dict[str, object]: + manifest_path = manifest_path.resolve() + output_path = output_path.resolve() + root = manifest_path.parent + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assets = sorted( + ( + asset + for asset in manifest["simulation_assets"] + if asset.get("collision_meshes") + ), + key=lambda asset: ( + str(asset["stream_file"]).casefold(), + str(asset["asset_id"]), + ), + ) + if expected_asset_count and len(assets) != expected_asset_count: + raise RuntimeError( + "retail collision asset count changed: " + f"expected {expected_asset_count}, got {len(assets)}" + ) + + records: list[tuple[str, bytes]] = [] + total_triangles = 0 + total_clusters = 0 + for asset_index, asset in enumerate(assets, 1): + rx2_path = root / Path(str(asset["rx2"]).replace("\\", "/")) + sections = collision_sections(rx2_path.read_bytes()) + metadata = asset["collision_meshes"] + if len(sections) != len(metadata): + raise RuntimeError( + f"{asset['asset_id']} collision section count changed: " + f"manifest={len(metadata)} RX2={len(sections)}" + ) + for mesh_index, (section, mesh) in enumerate( + zip(sections, metadata, strict=True) + ): + records.append( + (f"{asset['stream_file']}#{mesh_index}", section) + ) + total_triangles += int(mesh["triangles"]) + total_clusters += int(mesh["clusters"]) + if asset_index % 100 == 0: + print( + f"Read {asset_index}/{len(assets)} retail collision assets", + flush=True, + ) + if expected_mesh_count and len(records) != expected_mesh_count: + raise RuntimeError( + "retail collision mesh count changed: " + f"expected {expected_mesh_count}, got {len(records)}" + ) + + payload = bytearray(MAGIC) + payload.extend(struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--expected-asset-count", type=int, default=0) + parser.add_argument("--expected-mesh-count", type=int, default=0) + args = parser.parse_args() + if args.expected_asset_count < 0 or args.expected_mesh_count < 0: + raise ValueError("expected counts must be non-negative") + build_archive( + args.manifest, + args.output, + expected_asset_count=args.expected_asset_count, + expected_mesh_count=args.expected_mesh_count, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/build_skate2_lightmap_aliases.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_skate2_lightmap_aliases.py new file mode 100644 index 00000000..1ac4556d --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_skate2_lightmap_aliases.py @@ -0,0 +1,367 @@ +"""Map Skate 2 symbolic lightmap pages onto the existing decoded image cache.""" + +from __future__ import annotations + +import argparse +from collections import defaultdict +import hashlib +import json +from pathlib import Path +import re +import struct + + +LAYERPAGE_TEXTURE_PATTERN = re.compile( + rb"(layerpage_[^\x00\r\n]+?)\.Texture" +) +CHROMATICITY_TEXTURE_PATTERN = re.compile( + rb"(chromo_[^\x00\r\n]+?)\.Texture" +) +RX2_TOC_RECORD_SIZE = 24 +RX2_TYPE_MATERIAL = 0x00EB0005 + + +def _png_dimensions(path: Path) -> tuple[int, int]: + header = path.read_bytes()[:24] + if ( + len(header) != 24 + or header[:8] != b"\x89PNG\r\n\x1a\n" + or header[12:16] != b"IHDR" + ): + raise ValueError(f"{path} is not a valid PNG") + return struct.unpack(">II", header[16:24]) + + +def _select_aliases( + candidates: dict[str, list[dict[str, object]]], +) -> tuple[dict[str, dict[str, object]], int]: + aliases: dict[str, dict[str, object]] = {} + duplicate_names = 0 + for source_name, pages in sorted(candidates.items()): + if len(pages) > 1: + duplicate_names += 1 + largest_area = max( + int(page["width"]) * int(page["height"]) + for page in pages + ) + largest = [ + page + for page in pages + if int(page["width"]) * int(page["height"]) == largest_area + ] + largest_payloads = { + ( + int(page["width"]), + int(page["height"]), + str(page["png_sha256"]), + ) + for page in largest + } + if len(largest_payloads) != 1: + raise RuntimeError( + f"{source_name!r} has conflicting highest-resolution pages" + ) + selected = min( + largest, + key=lambda page: str(page["asset_id"]), + ) + aliases[source_name] = { + **selected, + "source_name": source_name, + "candidate_count": len(pages), + } + return aliases, duplicate_names + + +def _raw_lightmap_groups( + data: bytes, +) -> list[dict[str, object]]: + if len(data) < 0x34: + raise ValueError("RX2 is too small to contain a section table") + file_count = struct.unpack_from(">I", data, 0x20)[0] + file_table = struct.unpack_from(">I", data, 0x30)[0] + if file_table + file_count * RX2_TOC_RECORD_SIZE > len(data): + raise ValueError("RX2 section table extends beyond the file") + + groups: list[dict[str, object]] = [] + for record_index in range(file_count): + record_offset = file_table + record_index * RX2_TOC_RECORD_SIZE + section_offset, _, section_size, _, _ = struct.unpack_from( + ">5I", data, record_offset + ) + section_type = struct.unpack_from(">I", data, record_offset + 20)[0] + if section_type != RX2_TYPE_MATERIAL: + continue + if section_offset + 32 > len(data): + raise ValueError("RX2 material header extends beyond the file") + header = struct.unpack_from(">8I", data, section_offset) + parameter_count = header[1] + header_size = header[3] + parameters_size = header[4] + if parameter_count == 0: + continue + payload_size = parameters_size - header_size + if payload_size < 0 or payload_size % parameter_count: + raise ValueError("RX2 material parameter table has invalid sizes") + block_size = payload_size // parameter_count + if block_size != 32: + raise ValueError( + "RX2 material lightmap components require 32-byte records, " + f"got {block_size}" + ) + current: dict[str, object] | None = None + for parameter_index in range(parameter_count): + parameter_offset = ( + section_offset + header_size + parameter_index * block_size + ) + values = struct.unpack_from(">8I", data, parameter_offset) + + def source_string(relative_offset: int) -> str: + start = section_offset + relative_offset + if not section_offset <= start < section_offset + section_size: + raise ValueError( + "RX2 material string points outside its section" + ) + end = data.index(b"\0", start, section_offset + section_size) + return data[start:end].decode("ascii") + + kind = source_string(values[0]) + if kind == "Name": + current = {} + groups.append(current) + if current is None or kind != "lightmap": + continue + component = int(values[1]) + if component not in (0, 1, 2): + raise ValueError( + f"Skate 2 lightmap uses invalid component {component}" + ) + current["lightmap_component"] = component + current["lightmap"] = source_string(values[6]) + return groups + + +def build_aliases(cache_root: Path) -> dict[str, object]: + texture_root = cache_root / "rx2" / "textures" + image_root = cache_root / "textures" / "by_asset" + model_root = cache_root / "rx2" / "models" + if ( + not texture_root.is_dir() + or not image_root.is_dir() + or not model_root.is_dir() + ): + raise FileNotFoundError( + f"{cache_root} does not contain the Skate 2 texture/model cache" + ) + + layer_candidates: dict[str, list[dict[str, object]]] = defaultdict(list) + chromaticity_candidates: dict[ + str, list[dict[str, object]] + ] = defaultdict(list) + layer_occurrence_count = 0 + chromaticity_occurrence_count = 0 + for rx2_path in sorted(texture_root.glob("*.rx2")): + data = rx2_path.read_bytes() + layer_names = { + match.decode("ascii") + for match in LAYERPAGE_TEXTURE_PATTERN.findall(data) + } + chromaticity_names = { + match.decode("ascii") + for match in CHROMATICITY_TEXTURE_PATTERN.findall(data) + } + if not layer_names and not chromaticity_names: + continue + png_path = image_root / f"{rx2_path.stem.upper()}_0.png" + if not png_path.is_file(): + raise FileNotFoundError( + f"decoded Skate 2 lighting image is missing: {png_path}" + ) + width, height = _png_dimensions(png_path) + digest = hashlib.sha256(png_path.read_bytes()).hexdigest() + entry = { + "texture_id": f"asset_{rx2_path.stem.lower()}", + "asset_id": f"0x{rx2_path.stem.upper()}", + "rx2": str(rx2_path.relative_to(cache_root)), + "png": str(png_path.relative_to(cache_root)), + "width": width, + "height": height, + "png_sha256": digest, + } + for source_name in layer_names: + layer_occurrence_count += 1 + layer_candidates[source_name].append(entry) + for source_name in chromaticity_names: + chromaticity_occurrence_count += 1 + chromaticity_candidates[source_name].append(entry) + + aliases, duplicate_names = _select_aliases(layer_candidates) + chromaticity_aliases, duplicate_chromaticity_names = _select_aliases( + chromaticity_candidates + ) + if not aliases: + raise RuntimeError("the cache contains no Skate 2 layerpage textures") + if not chromaticity_aliases: + raise RuntimeError( + "the cache contains no Skate 2 chromaticity textures" + ) + manifest_path = cache_root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + mesh_bindings: dict[str, dict[str, object]] = {} + source_references = 0 + eligible_meshes = 0 + excluded_shader_meshes = 0 + excluded_no_uv_meshes = 0 + referenced_names: set[str] = set() + eligible_names: set[str] = set() + referenced_chromaticities: set[str] = set() + eligible_chromaticities: set[str] = set() + eligible_pairs: set[tuple[str, str, int]] = set() + component_counts = [0, 0, 0] + for model in manifest["models"]: + asset_id = str(model["asset_id"]).lower() + model_path = model_root / f"{asset_id[2:].upper()}.rx2" + if not model_path.is_file(): + raise FileNotFoundError( + f"decoded Skate 2 model is missing: {model_path}" + ) + raw_groups = _raw_lightmap_groups(model_path.read_bytes()) + for mesh in model["meshes"]: + values = mesh.get("retail_parameters", {}).get( + "lightmap", + [], + ) + if not values or not values[0]: + continue + source_name = str(values[0]) + chromaticity_values = mesh.get( + "retail_parameters", {} + ).get("chromaticity", []) + if not chromaticity_values or not chromaticity_values[0]: + raise RuntimeError( + f"{asset_id}:{mesh['index']} has a layer page without " + "chromaticity" + ) + chromaticity_name = str(chromaticity_values[0]) + group_index = int( + mesh.get("retail_material_group_index", -1) + ) + if not 0 <= group_index < len(raw_groups): + raise RuntimeError( + f"{asset_id}:{mesh['index']} has invalid material group " + f"{group_index} of {len(raw_groups)}" + ) + raw_group = raw_groups[group_index] + if raw_group.get("lightmap") != source_name: + raise RuntimeError( + f"{asset_id}:{mesh['index']} raw layer page disagrees " + "with the extraction manifest" + ) + component = int(raw_group["lightmap_component"]) + source_references += 1 + referenced_names.add(source_name) + referenced_chromaticities.add(chromaticity_name) + shader_name = str(mesh.get("shader_name") or "").lower() + reason = "" + eligible = True + if shader_name.startswith(("water.", "ocean.")): + eligible = False + reason = "shader" + excluded_shader_meshes += 1 + elif mesh.get("lightmap_uv") is None: + eligible = False + reason = "no_secondary_uv" + excluded_no_uv_meshes += 1 + else: + eligible_meshes += 1 + eligible_names.add(source_name) + eligible_chromaticities.add(chromaticity_name) + eligible_pairs.add( + (source_name, chromaticity_name, component) + ) + component_counts[component] += 1 + mesh_key = f"{asset_id}:{int(mesh['index'])}" + if mesh_key in mesh_bindings: + raise RuntimeError( + f"duplicate Skate 2 mesh identity {mesh_key}" + ) + mesh_bindings[mesh_key] = { + "source_name": source_name, + "chromaticity_name": chromaticity_name, + "lightmap_component": component, + "eligible": eligible, + "exclusion": reason, + } + missing_references = referenced_names - set(aliases) + if missing_references: + raise RuntimeError( + "decoded layer pages are missing for material references: " + f"{sorted(missing_references)[:10]}" + ) + missing_chromaticities = referenced_chromaticities - set( + chromaticity_aliases + ) + if missing_chromaticities: + raise RuntimeError( + "decoded chromaticity pages are missing for material references: " + f"{sorted(missing_chromaticities)[:10]}" + ) + return { + "format": "skate2-lightmap-aliases-v2", + "cache_root": str(cache_root.resolve()), + "aliases": aliases, + "chromaticity_aliases": chromaticity_aliases, + "mesh_bindings": mesh_bindings, + "summary": { + "unique_lightmap_pages": len(aliases), + "asset_occurrences": layer_occurrence_count, + "duplicate_page_names": duplicate_names, + "unique_chromaticity_pages": len(chromaticity_aliases), + "chromaticity_asset_occurrences": ( + chromaticity_occurrence_count + ), + "duplicate_chromaticity_names": ( + duplicate_chromaticity_names + ), + "decoded_rgba_bytes": sum( + int(page["width"]) * int(page["height"]) * 4 + for page in aliases.values() + ), + "decoded_chromaticity_rgba_bytes": sum( + int(page["width"]) * int(page["height"]) * 4 + for page in chromaticity_aliases.values() + ), + "source_references": source_references, + "eligible_meshes": eligible_meshes, + "eligible_lightmap_pages": len(eligible_names), + "eligible_chromaticity_pages": len( + eligible_chromaticities + ), + "eligible_lightmap_chromaticity_pairs": len(eligible_pairs), + "eligible_component_counts": component_counts, + "excluded_shader_meshes": excluded_shader_meshes, + "excluded_no_uv_meshes": excluded_no_uv_meshes, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("cache_root", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + + result = build_aliases(args.cache_root.resolve()) + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(result, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps(result["summary"], indent=2)) + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/build_university_collision_probe.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_university_collision_probe.py new file mode 100644 index 00000000..dc4f403f --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/build_university_collision_probe.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Build an exact-retail collision archive for the University district.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import struct + + +RX2_COLLISION_TYPE = 0x00080006 +MAGIC = b"RWCMSET1" +CELL_NAME = re.compile( + r"^cSim_(-?\d+)_(-?\d+)_high\.xsf$", re.IGNORECASE +) +SPAWN_XZ = (330.0, -710.0) +DEFAULT_ASSET_COUNT = 0 +EXPECTED_FULL_ASSET_COUNT = 299 +EXPECTED_FULL_MESH_COUNT = 301 +REQUIRED_DIAGNOSTIC_ASSET = "0xDE463D6F91D30023" + + +def _be_u32(data: bytes, offset: int) -> int: + return struct.unpack_from(">I", data, offset)[0] + + +def _collision_sections(rx2: bytes) -> list[bytes]: + if len(rx2) < 0x34 or rx2[:7] != b"\x89RW4xb2": + raise ValueError("input is not an Xbox 360 RW4 RX2 resource") + count = _be_u32(rx2, 0x20) + table = _be_u32(rx2, 0x30) + if table + count * 24 > len(rx2): + raise ValueError("RX2 section table extends beyond the resource") + result: list[bytes] = [] + for index in range(count): + record = table + index * 24 + offset = _be_u32(rx2, record) + size = _be_u32(rx2, record + 8) + section_type = _be_u32(rx2, record + 20) + if section_type != RX2_COLLISION_TYPE: + continue + if offset + size > len(rx2): + raise ValueError("RX2 collision section extends beyond the resource") + result.append(rx2[offset : offset + size]) + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--asset-count", type=int, default=DEFAULT_ASSET_COUNT) + args = parser.parse_args() + if args.asset_count < 0: + raise ValueError("asset count must be zero (all) or positive") + + manifest_path = args.manifest.resolve() + root = manifest_path.parent + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + candidates: list[tuple[float, str, dict[str, object]]] = [] + for asset in manifest["simulation_assets"]: + if not asset.get("collision_meshes"): + continue + match = CELL_NAME.match(str(asset["stream_file"])) + if match is None: + continue + center_x, center_z = (float(value) for value in match.groups()) + distance_squared = ( + (center_x - SPAWN_XZ[0]) ** 2 + + (center_z - SPAWN_XZ[1]) ** 2 + ) + candidates.append( + (distance_squared, str(asset["asset_id"]), asset) + ) + candidates.sort(key=lambda item: (item[0], item[1])) + selected = ( + candidates + if args.asset_count == 0 + else candidates[: args.asset_count] + ) + selected_ids = {asset_id for _distance, asset_id, _asset in selected} + if REQUIRED_DIAGNOSTIC_ASSET not in selected_ids: + raise RuntimeError( + "spawn collision selection omitted the known Mega Park " + f"diagnostic asset {REQUIRED_DIAGNOSTIC_ASSET}" + ) + + records: list[tuple[str, bytes]] = [] + selected_assets: list[dict[str, object]] = [] + for distance_squared, asset_id, asset in selected: + rx2_path = root / Path(str(asset["rx2"]).replace("\\", "/")) + sections = _collision_sections(rx2_path.read_bytes()) + if len(sections) != len(asset["collision_meshes"]): + raise RuntimeError( + f"{asset_id} collision section count changed" + ) + for mesh_index, section in enumerate(sections): + records.append( + ( + f"{asset['stream_file']}#{mesh_index}", + section, + ) + ) + selected_assets.append( + { + "asset_id": asset_id, + "stream_file": asset["stream_file"], + "distance": distance_squared**0.5, + "meshes": len(sections), + } + ) + if args.asset_count == 0: + if len(selected) != EXPECTED_FULL_ASSET_COUNT: + raise RuntimeError( + "full University collision asset count changed: " + f"expected {EXPECTED_FULL_ASSET_COUNT}, got {len(selected)}" + ) + if len(records) != EXPECTED_FULL_MESH_COUNT: + raise RuntimeError( + "full University collision mesh count changed: " + f"expected {EXPECTED_FULL_MESH_COUNT}, got {len(records)}" + ) + + payload = bytearray(MAGIC) + payload.extend(struct.pack(" list[bytes]: + vertex_bytes = package["_vertex_bytes"] + indices = package["_indices"] + digests: list[bytes] = [] + for first_corner in range(0, len(indices), 3): + digest = hashlib.blake2b(digest_size=16) + for index in indices[first_corner : first_corner + 3]: + offset = index * 44 + digest.update(vertex_bytes[offset : offset + 40]) + digests.append(digest.digest()) + digests.sort() + return digests + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("before", type=Path) + parser.add_argument("after", type=Path) + args = parser.parse_args() + + before = analyze_package(args.before, include_payloads=True) + after = analyze_package(args.after, include_payloads=True) + failures: list[str] = [] + + for count_name in ("textures", "indices"): + if before["counts"][count_name] != after["counts"][count_name]: + failures.append( + f"{count_name} changed: {before['counts'][count_name]} -> " + f"{after['counts'][count_name]}" + ) + if ( + before["integrity"]["decoded_textures_sha256"] + != after["integrity"]["decoded_textures_sha256"] + ): + failures.append("decoded texture payloads changed") + for bound_name in ("bounds_min", "bounds_max"): + if before["integrity"][bound_name] != after["integrity"][bound_name]: + failures.append(f"{bound_name} changed") + + ordered_stream_matches = True + maximum_ordered_float_delta = 0.0 + if before["counts"]["indices"] == after["counts"]["indices"]: + before_vertices = before["_vertex_bytes"] + after_vertices = after["_vertex_bytes"] + for corner, (before_index, after_index) in enumerate( + zip(before["_indices"], after["_indices"], strict=True) + ): + before_record = struct.unpack_from( + "<10fI", before_vertices, before_index * 44 + ) + after_record = struct.unpack_from( + "<10fI", after_vertices, after_index * 44 + ) + delta = max( + abs(left - right) + for left, right in zip( + before_record[:10], after_record[:10], strict=True + ) + ) + maximum_ordered_float_delta = max( + maximum_ordered_float_delta, delta + ) + if delta > 1.0e-6: + ordered_stream_matches = False + break + + geometry_multiset_matches = ( + _triangle_geometry_digests(before) + == _triangle_geometry_digests(after) + ) + if not geometry_multiset_matches: + failures.append( + "position/normal/UV triangle multiset changed after " + "normalizing export order and material IDs" + ) + + if failures: + print("UNIVERSITY_MATERIAL_FIX_MISMATCH") + for failure in failures: + print(f"- {failure}") + return 1 + + print( + "UNIVERSITY_MATERIAL_FIX_OK", + f"triangles={after['counts']['indices'] // 3}", + f"ordered_stream_matches={str(ordered_stream_matches).lower()}", + f"maximum_ordered_float_delta={maximum_ordered_float_delta:.9g}", + "geometry_multiset=unchanged", + "material_assignments=intentionally_changed", + f"textures={after['counts']['textures']}", + "texture_payloads=unchanged", + f"collision_triangles_before=" + f"{before['counts']['collision_triangles']}", + f"collision_triangles_after=" + f"{after['counts']['collision_triangles']}", + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except PackageError as error: + raise SystemExit(f"SKATE_PACKAGE_ERROR {error}") from error diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/embed_retail_collision_archive.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/embed_retail_collision_archive.py new file mode 100644 index 00000000..20b0d55e --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/embed_retail_collision_archive.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Embed an exact RWCMSET1 collision archive in a SKATE v12-v14 package. + +The resulting map remains portable because its ordinary collision triangles +are left untouched. The RWCM extension is an optional exact-retail payload +used first by compatible runtimes; older runtimes can ignore the extension. +Re-running this tool replaces any existing RWCM record without disturbing +other extensions such as SKYB, MOBJ, RCID, or BMAT. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct + +from export_skate2_skybox import ( + COPY_BYTES, + PackageError, + _find_extension_offset, + _read_extensions, + _stored, +) + + +ARCHIVE_MAGIC = b"RWCMSET1" +EXTENSION_TAG = b"RWCM" +EXTENSION_SCHEMA = 1 +MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024 + + +def validate_archive(data: bytes) -> dict[str, int]: + if len(data) < 12 or len(data) > MAXIMUM_ARCHIVE_BYTES: + raise PackageError("retail collision archive size is invalid") + if data[:8] != ARCHIVE_MAGIC: + raise PackageError("retail collision archive magic is invalid") + mesh_count = struct.unpack_from(" 16 * 1024 * 1024: + raise PackageError("retail collision archive mesh count is invalid") + + cursor = 12 + mesh_payload_bytes = 0 + for index in range(mesh_count): + if cursor + 4 > len(data): + raise PackageError(f"archive mesh {index} name is truncated") + name_size = struct.unpack_from(" 4096 + or cursor + name_size + 4 > len(data) + ): + raise PackageError(f"archive mesh {index} name is invalid") + try: + data[cursor : cursor + name_size].decode("utf-8") + except UnicodeDecodeError as error: + raise PackageError( + f"archive mesh {index} name is not valid UTF-8" + ) from error + cursor += name_size + mesh_size = struct.unpack_from(" len(data): + raise PackageError(f"archive mesh {index} payload is invalid") + cursor += mesh_size + mesh_payload_bytes += mesh_size + if cursor != len(data): + raise PackageError("retail collision archive has trailing bytes") + return { + "meshes": mesh_count, + "mesh_payload_bytes": mesh_payload_bytes, + } + + +def embed_archive( + source: Path, + archive: Path, + destination: Path, +) -> dict[str, object]: + source = source.resolve() + archive = archive.resolve() + destination = destination.resolve() + if source == destination: + raise ValueError("destination must differ from source") + if archive == destination: + raise ValueError("archive and destination must differ") + + archive_data = archive.read_bytes() + archive_report = validate_archive(archive_data) + extension_offset = _find_extension_offset(source) + records = [ + record + for tag, record in _read_extensions(source, extension_offset) + if tag != EXTENSION_TAG + ] + encoded = ( + EXTENSION_TAG + + struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("archive", type=Path) + parser.add_argument("output", type=Path) + arguments = parser.parse_args() + print( + json.dumps( + embed_archive( + arguments.source, + arguments.archive, + arguments.output, + ), + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/export_skate2_skybox.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/export_skate2_skybox.py new file mode 100644 index 00000000..6a402e3f --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/export_skate2_skybox.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +"""Extract Skate 2's retail sky and attach it to a SKATE v12-v14 map. + +The source is ``miscboot.big``. Only ``DIST_skybox.rx2`` and its texture RX2 +are read; proxy mountains, advertising signs, and unrelated textures in the +same resources are deliberately excluded. The destination package is copied +streamingly and receives a replaceable ``SKYB`` extension, so an 800+ MiB map +does not need to pass through Blender again. +""" + +from __future__ import annotations + +import argparse +import importlib +import io +import os +from pathlib import Path, PureWindowsPath +import shutil +import struct +import sys +import zlib + +from extract_skate2_big4 import read_entries +from skate3_streams import decompress_refpack + + +MODEL_ENTRY = r"data\content\world\models\DIST_skybox.rx2" +TEXTURE_ENTRY = r"data\content\world\models\DIST_skybox_Textures.rx2" +SKY_TEXTURE_NAMES = ( + "sky_detail_0x0000727a03e3870a", + "sky_gradient_0x0000727903e3870a", + "sun_gradient_0x0000727803e3870a", +) +SKY_TEXTURE_INDICES = { + SKY_TEXTURE_NAMES[0]: 0, + SKY_TEXTURE_NAMES[1]: 3, + SKY_TEXTURE_NAMES[2]: 4, +} +COPY_BYTES = 8 * 1024 * 1024 + + +class PackageError(ValueError): + """Raised when a package cannot be patched without guessing.""" + + +class FileReader: + def __init__(self, stream: io.BufferedReader) -> None: + self.stream = stream + + @property + def offset(self) -> int: + return self.stream.tell() + + def take(self, size: int, label: str) -> bytes: + data = self.stream.read(size) + if size < 0 or len(data) != size: + raise PackageError(f"{label} extends past the end of the package") + return data + + def skip(self, size: int, label: str) -> None: + if size < 0: + raise PackageError(f"{label} has a negative size") + self.stream.seek(size, os.SEEK_CUR) + + def u32(self, label: str) -> int: + return struct.unpack(" int: + return struct.unpack(" str: + size = self.u32(f"{label} length") + if size > 64 * 1024: + raise PackageError(f"{label} exceeds the string limit") + return self.take(size, label).decode("utf-8") + + def stored(self, label: str) -> None: + method = self.u32(f"{label} storage method") + stored_size = self.u32(f"{label} stored size") + if method not in (0, 1, 2): + raise PackageError(f"{label} uses unsupported storage method {method}") + self.skip(stored_size, f"{label} payload") + + +def _read_big4_entry(archive: Path, name: str) -> bytes: + normalized = str(PureWindowsPath(name)).casefold() + matches = [ + entry + for entry in read_entries(archive) + if str(PureWindowsPath(entry.name)).casefold() == normalized + ] + if len(matches) != 1: + raise ValueError(f"BIG4 contains {len(matches)} matches for {name!r}") + entry = matches[0] + with archive.open("rb") as stream: + stream.seek(entry.offset) + data = stream.read(entry.size) + if len(data) != entry.size: + raise ValueError(f"BIG4 entry {name!r} is truncated") + return decompress_refpack(data) + + +def _load_utt(utt_root: Path): + if not (utt_root / "mdl_parser" / "parser.py").is_file(): + raise ValueError(f"UTT model parser was not found under {utt_root}") + sys.path.insert(0, str(utt_root)) + model_parser = importlib.import_module("mdl_parser.parser") + texture_parser = importlib.import_module("rx2_parser") + return model_parser, texture_parser + + +def _stored(data: bytes) -> bytes: + compressed = zlib.compress(data, 9) + if len(compressed) < len(data): + return struct.pack(" bytes: + encoded = value.encode("utf-8") + return struct.pack(" tuple[bytes, dict[str, object]]: + model_parser, texture_parser = _load_utt(utt_root) + model = model_parser.parse_rx2(model_data) + sky_meshes = [ + mesh + for mesh in model.meshes + if str(mesh.name).casefold().startswith("skybox") + ] + if len(sky_meshes) != 1: + raise ValueError( + f"expected one named skybox mesh; found {len(sky_meshes)}" + ) + mesh = sky_meshes[0] + if len(mesh.vertices) == 0 or len(mesh.faces) == 0: + raise ValueError("retail skybox mesh is empty") + if len(mesh.vertices) > 65535: + raise ValueError("retail skybox exceeds the renderer vertex limit") + + texture_file = texture_parser.parse_rx2(texture_data) + if len(texture_file.textures) != 8: + raise ValueError( + "DIST_skybox_Textures.rx2 no longer has the expected 8 textures" + ) + selected = { + name: texture_file.textures[index] + for name, index in SKY_TEXTURE_INDICES.items() + } + expected_dimensions = { + SKY_TEXTURE_NAMES[0]: (2048, 256), + SKY_TEXTURE_NAMES[1]: (512, 64), + SKY_TEXTURE_NAMES[2]: (512, 16), + } + for name, texture in selected.items(): + if (texture.width, texture.height) != expected_dimensions[name]: + raise ValueError( + f"{name} is {texture.width}x{texture.height}; expected " + f"{expected_dimensions[name][0]}x{expected_dimensions[name][1]}" + ) + + vertices = bytearray() + for position, uv in zip(mesh.vertices, mesh.uvs, strict=True): + vertices.extend( + struct.pack( + "<5f", + float(position[0]), + float(position[1]), + float(position[2]), + float(uv[0]), + float(uv[1]), + ) + ) + indices = bytearray() + for face in mesh.faces: + indices.extend( + struct.pack("<3I", int(face[0]), int(face[1]), int(face[2])) + ) + + payload = bytearray() + payload.extend(struct.pack(" int: + with path.open("rb") as stream: + reader = FileReader(stream) + magic = reader.take(8, "magic") + if magic not in (b"SKATE12\0", b"SKATE13\0", b"SKATE14\0"): + raise PackageError( + f"{path} is not a SKATE v12-v14 package ({magic!r})" + ) + version = int(magic[5:7]) + if reader.u32("endian marker") != 0x12345678: + raise PackageError("package endian marker is invalid") + reader.string("map name") + reader.skip(49 * 4, "map metadata") + counts = struct.unpack("<9I", reader.take(36, "count table")) + ( + material_count, + texture_count, + vertex_count, + index_count, + collision_count, + rail_count, + door_count, + light_count, + route_count, + ) = counts + + for index in range(material_count): + reader.string(f"material {index} name") + reader.skip(76, f"material {index} fields") + if version >= 13: + reader.u32(f"material {index} depth layer") + if reader.u32(f"material {index} retail enabled"): + reader.skip(16, f"material {index} retail ids") + reader.string(f"material {index} retail shader") + reader.skip(8, f"material {index} retail family/flags") + binding_count = reader.u32(f"material {index} binding count") + for binding in range(binding_count): + reader.string(f"material {index} binding {binding}") + reader.skip(16, f"material {index} binding fields") + parameter_count = reader.u32( + f"material {index} parameter count" + ) + for parameter in range(parameter_count): + reader.string(f"material {index} parameter {parameter}") + value_count = reader.u32( + f"material {index} parameter {parameter} value count" + ) + for value in range(value_count): + reader.string( + f"material {index} parameter {parameter} value {value}" + ) + reader.string(f"material {index} source metadata") + + for index in range(texture_count): + reader.string(f"texture {index} name") + reader.skip(12, f"texture {index} dimensions") + reader.stored(f"texture {index}") + reader.stored("visual vertices") + reader.stored("visual indices") + reader.stored("collision") + + for index in range(rail_count): + reader.string(f"rail {index} name") + reader.u32(f"rail {index} closed") + representation = reader.u32(f"rail {index} representation") + if representation == 0: + reader.skip( + reader.u32(f"rail {index} point count") * 12, + f"rail {index} points", + ) + elif representation == 1: + reader.skip(24, f"rail {index} native header") + reader.skip( + reader.u32(f"rail {index} segment count") * 120, + f"rail {index} segments", + ) + else: + raise PackageError( + f"rail {index} has unsupported representation " + f"{representation}" + ) + + vertex_stride = 56 + collision_stride = 48 + for index in range(door_count): + reader.string(f"door {index} name") + reader.skip(28 * 4 + 4, f"door {index} fields") + door_vertices = reader.u32(f"door {index} vertex count") + door_indices = reader.u32(f"door {index} index count") + door_collision = reader.u32(f"door {index} collision count") + reader.skip( + door_vertices * vertex_stride + + door_indices * 4 + + door_collision * collision_stride, + f"door {index} geometry", + ) + for index in range(light_count): + reader.string(f"light {index} name") + reader.skip(4 + 14 * 4, f"light {index} fields") + for index in range(route_count): + reader.string(f"route {index} name") + reader.skip(16, f"route {index} fields") + reader.skip( + reader.u32(f"route {index} point count") * 12, + f"route {index} points", + ) + return reader.offset + + +def _read_extensions(path: Path, offset: int) -> list[tuple[bytes, bytes]]: + records: list[tuple[bytes, bytes]] = [] + with path.open("rb") as stream: + stream.seek(offset) + reader = FileReader(stream) + count = reader.u32("extension count") + if count > 1024: + raise PackageError("extension count is implausible") + for index in range(count): + start = reader.offset + tag = reader.take(4, f"extension {index} tag") + reader.u32(f"extension {index} schema") + reader.u32(f"extension {index} decoded size") + reader.stored(f"extension {index}") + end = reader.offset + stream.seek(start) + records.append((tag, stream.read(end - start))) + stream.seek(end) + stream.seek(0, os.SEEK_END) + if reader.offset != stream.tell(): + raise PackageError("package has trailing bytes after extensions") + return records + + +def attach_skyb(source: Path, destination: Path, payload: bytes) -> None: + source = source.resolve() + destination = destination.resolve() + if source == destination: + raise ValueError("destination must differ from source") + offset = _find_extension_offset(source) + records = [ + record + for tag, record in _read_extensions(source, offset) + if tag != b"SKYB" + ] + encoded = ( + b"SKYB" + + struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--utt-root", type=Path, required=True) + parser.add_argument("--source-map", type=Path, required=True) + parser.add_argument("--output-map", type=Path, required=True) + parser.add_argument("--collision-sidecar", type=Path) + parser.add_argument("--output-sidecar", type=Path) + args = parser.parse_args() + + model_data = _read_big4_entry(args.archive.resolve(), MODEL_ENTRY) + texture_data = _read_big4_entry(args.archive.resolve(), TEXTURE_ENTRY) + payload, report = build_skyb_payload( + model_data, texture_data, args.utt_root.resolve() + ) + attach_skyb( + args.source_map.resolve(), args.output_map.resolve(), payload + ) + if args.collision_sidecar or args.output_sidecar: + if not args.collision_sidecar or not args.output_sidecar: + parser.error( + "--collision-sidecar and --output-sidecar must be used together" + ) + args.output_sidecar.resolve().parent.mkdir( + parents=True, exist_ok=True + ) + shutil.copy2( + args.collision_sidecar.resolve(), + args.output_sidecar.resolve(), + ) + print( + "Skate 2 SKYB attached:", + args.output_map.resolve(), + f"mesh={report['mesh']}", + f"vertices={report['vertices']}", + f"triangles={report['triangles']}", + f"payload={len(payload):,}", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_big4.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_big4.py new file mode 100644 index 00000000..de48309e --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_big4.py @@ -0,0 +1,161 @@ +"""List or extract Skate 2 BIG4 archives. + +Skate 2's Xbox 360 archives use an EA BIG4 file table, but the legacy +``bigfile.exe`` bundled with UTT rejects this particular container variant. +The files themselves are stored verbatim, so only a small bounded table reader +is needed before the existing Skate stream extractor can take over. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import os +from pathlib import Path, PureWindowsPath + + +COPY_CHUNK_SIZE = 8 * 1024 * 1024 + + +@dataclass(frozen=True) +class Big4Entry: + offset: int + size: int + name: str + + +def _read_exact(stream, size: int) -> bytes: + data = stream.read(size) + if len(data) != size: + raise ValueError("unexpected end of BIG4 archive") + return data + + +def read_entries(archive_path: Path) -> list[Big4Entry]: + archive_size = archive_path.stat().st_size + with archive_path.open("rb") as stream: + if _read_exact(stream, 4) != b"BIG4": + raise ValueError(f"{archive_path} is not a BIG4 archive") + + stored_size_bytes = _read_exact(stream, 4) + stored_size_be = int.from_bytes(stored_size_bytes, "big") + stored_size_le = int.from_bytes(stored_size_bytes, "little") + if archive_size not in (stored_size_be, stored_size_le): + raise ValueError( + "BIG4 stored archive size does not match the input file: " + f"{stored_size_be} (BE), {stored_size_le} (LE), " + f"actual {archive_size}" + ) + + entry_count = int.from_bytes(_read_exact(stream, 4), "big") + header_size = int.from_bytes(_read_exact(stream, 4), "big") + if not 16 <= header_size <= archive_size: + raise ValueError(f"invalid BIG4 header size {header_size}") + + entries: list[Big4Entry] = [] + normalized_names: set[str] = set() + for index in range(entry_count): + offset = int.from_bytes(_read_exact(stream, 4), "big") + size = int.from_bytes(_read_exact(stream, 4), "big") + name_bytes = bytearray() + while True: + byte = _read_exact(stream, 1) + if byte == b"\0": + break + name_bytes.extend(byte) + if len(name_bytes) > 4096: + raise ValueError(f"entry {index} has an invalid path") + name = name_bytes.decode("ascii") + normalized_name = str(PureWindowsPath(name)).casefold() + if normalized_name in normalized_names: + raise ValueError( + f"BIG4 contains a duplicate output path: {name!r}" + ) + normalized_names.add(normalized_name) + if offset < header_size or offset + size > archive_size: + raise ValueError( + f"entry {index} is outside the archive: " + f"{name!r}, offset={offset}, size={size}" + ) + entries.append(Big4Entry(offset=offset, size=size, name=name)) + + if stream.tell() > header_size: + raise ValueError( + f"BIG4 file table ends at {stream.tell()}, " + f"past declared header size {header_size}" + ) + return entries + + +def _safe_output_path(output_root: Path, entry_name: str) -> Path: + windows_path = PureWindowsPath(entry_name) + if windows_path.is_absolute() or windows_path.drive: + raise ValueError(f"unsafe absolute BIG4 entry path: {entry_name!r}") + if any(part in ("", ".", "..") for part in windows_path.parts): + raise ValueError(f"unsafe BIG4 entry path: {entry_name!r}") + + candidate = output_root.joinpath(*windows_path.parts).resolve() + try: + candidate.relative_to(output_root) + except ValueError as error: + raise ValueError(f"BIG4 entry escapes output root: {entry_name!r}") from error + return candidate + + +def extract_archive( + archive_path: Path, + output_root: Path, + entries: list[Big4Entry], +) -> None: + output_root.mkdir(parents=True, exist_ok=True) + output_root = output_root.resolve() + with archive_path.open("rb") as source: + for index, entry in enumerate(entries, start=1): + destination = _safe_output_path(output_root, entry.name) + if destination.is_file() and destination.stat().st_size == entry.size: + print(f"[{index}/{len(entries)}] cached {entry.name}") + continue + destination.parent.mkdir(parents=True, exist_ok=True) + source.seek(entry.offset) + remaining = entry.size + temporary = destination.with_name(destination.name + ".tmp") + with temporary.open("wb") as target: + while remaining: + block = source.read(min(remaining, COPY_CHUNK_SIZE)) + if not block: + raise ValueError( + f"unexpected end of archive while extracting {entry.name!r}" + ) + target.write(block) + remaining -= len(block) + os.replace(temporary, destination) + print(f"[{index}/{len(entries)}] extracted {entry.name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("archive", type=Path) + parser.add_argument( + "--output", + type=Path, + help="Output directory. Omit to validate and list the archive.", + ) + args = parser.parse_args() + + archive_path = args.archive.resolve() + entries = read_entries(archive_path) + total_size = sum(entry.size for entry in entries) + print( + f"{archive_path.name}: {len(entries)} entries, " + f"{total_size:,} stored bytes" + ) + if args.output is None: + for entry in entries: + print(f"{entry.offset:12d} {entry.size:12d} {entry.name}") + else: + extract_archive(archive_path, args.output, entries) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_shader_ucode.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_shader_ucode.py new file mode 100644 index 00000000..52f8eaf4 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/extract_skate2_shader_ucode.py @@ -0,0 +1,74 @@ +"""Extract raw Xenos microcode from Skate 2's RefPack FPO/VPO objects.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import struct + +from skate3_streams import decompress_refpack + + +OBJECT_MAGICS = { + ".fpo": 0x102A1100, + ".vpo": 0x102A1101, +} +LITERAL_CONSTANT_BYTES = 64 + + +def extract_shader(source: Path, output: Path) -> None: + decoded = decompress_refpack(source.read_bytes()) + if len(decoded) < 12: + raise ValueError(f"{source} has a truncated shader object") + magic, microcode_offset, microcode_size = struct.unpack_from( + ">III", decoded + ) + expected_magic = OBJECT_MAGICS.get(source.suffix.lower()) + if expected_magic is None: + raise ValueError(f"{source} is not an FPO or VPO shader object") + if magic != expected_magic: + raise ValueError( + f"{source} has unexpected shader magic 0x{magic:08X}" + ) + end = microcode_offset + microcode_size + if ( + microcode_offset < 12 + or microcode_size < LITERAL_CONSTANT_BYTES + 8 + or microcode_size % 4 + or end != len(decoded) + ): + raise ValueError( + f"{source} has invalid microcode range " + f"{microcode_offset}:{end} of {len(decoded)}" + ) + # The serialized Xbox shader object prefixes the actual control-flow + # stream with four literal float4 constants. Xenia's Shader analyzer + # consumes the raw control-flow and ALU/fetch stream only. + ucode_start = microcode_offset + LITERAL_CONSTANT_BYTES + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(decoded[ucode_start:end]) + print( + f"{source.name}: {len(source.read_bytes())} stored -> " + f"{len(decoded)} object -> " + f"{microcode_size - LITERAL_CONSTANT_BYTES} ucode bytes -> {output}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("shaders", nargs="+", type=Path) + args = parser.parse_args() + output_root = args.output.resolve() + for source in args.shaders: + source = source.resolve() + prefix = "ps_" if source.suffix.lower() == ".fpo" else "vs_" + extract_shader( + source, + output_root / f"{prefix}{source.stem}.bin", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/inspect_university_point.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/inspect_university_point.py new file mode 100644 index 00000000..f405ffe0 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/inspect_university_point.py @@ -0,0 +1,378 @@ +"""Inspect visual and collision geometry near a University map coordinate.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +from pathlib import Path +import struct +import sys +from typing import Iterable + + +COLLISION_RECORD = struct.Struct("<9fII4B") +VISUAL_VERTEX = struct.Struct("<10fI") + + +def _load_analyzer(source_root: Path): + analyzer_path = ( + source_root / "tools" / "blender_owned_map" / "analyze_skate.py" + ) + spec = importlib.util.spec_from_file_location( + "owned_map_analyze_skate", analyzer_path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load package analyzer: {analyzer_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _sub(a: tuple[float, float, float], b: tuple[float, float, float]): + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def _dot(a: tuple[float, float, float], b: tuple[float, float, float]): + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _cross(a: tuple[float, float, float], b: tuple[float, float, float]): + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def _length_squared(value: tuple[float, float, float]) -> float: + return _dot(value, value) + + +def _normal( + a: tuple[float, float, float], + b: tuple[float, float, float], + c: tuple[float, float, float], +) -> tuple[float, float, float]: + value = _cross(_sub(b, a), _sub(c, a)) + length = math.sqrt(_length_squared(value)) + if length <= 1.0e-12: + return (0.0, 0.0, 0.0) + return (value[0] / length, value[1] / length, value[2] / length) + + +def _point_triangle_distance_squared( + point: tuple[float, float, float], + a: tuple[float, float, float], + b: tuple[float, float, float], + c: tuple[float, float, float], +) -> float: + # Real-Time Collision Detection, Christer Ericson, section 5.1.5. + ab = _sub(b, a) + ac = _sub(c, a) + ap = _sub(point, a) + d1 = _dot(ab, ap) + d2 = _dot(ac, ap) + if d1 <= 0.0 and d2 <= 0.0: + return _length_squared(ap) + + bp = _sub(point, b) + d3 = _dot(ab, bp) + d4 = _dot(ac, bp) + if d3 >= 0.0 and d4 <= d3: + return _length_squared(bp) + + vc = d1 * d4 - d3 * d2 + if vc <= 0.0 and d1 >= 0.0 and d3 <= 0.0: + v = d1 / (d1 - d3) + closest = (a[0] + v * ab[0], a[1] + v * ab[1], a[2] + v * ab[2]) + return _length_squared(_sub(point, closest)) + + cp = _sub(point, c) + d5 = _dot(ab, cp) + d6 = _dot(ac, cp) + if d6 >= 0.0 and d5 <= d6: + return _length_squared(cp) + + vb = d5 * d2 - d1 * d6 + if vb <= 0.0 and d2 >= 0.0 and d6 <= 0.0: + w = d2 / (d2 - d6) + closest = (a[0] + w * ac[0], a[1] + w * ac[1], a[2] + w * ac[2]) + return _length_squared(_sub(point, closest)) + + va = d3 * d6 - d5 * d4 + if va <= 0.0 and d4 - d3 >= 0.0 and d5 - d6 >= 0.0: + bc = _sub(c, b) + w = (d4 - d3) / ((d4 - d3) + (d5 - d6)) + closest = (b[0] + w * bc[0], b[1] + w * bc[1], b[2] + w * bc[2]) + return _length_squared(_sub(point, closest)) + + denominator = 1.0 / (va + vb + vc) + v = vb * denominator + w = vc * denominator + closest = ( + a[0] + ab[0] * v + ac[0] * w, + a[1] + ab[1] * v + ac[1] * w, + a[2] + ab[2] * v + ac[2] * w, + ) + return _length_squared(_sub(point, closest)) + + +def _within_expanded_bounds( + point: tuple[float, float, float], + radius: float, + vertices: Iterable[tuple[float, float, float]], +) -> bool: + values = tuple(vertices) + return all( + min(vertex[axis] for vertex in values) - radius <= point[axis] + <= max(vertex[axis] for vertex in values) + radius + for axis in range(3) + ) + + +def _triangle_result( + index: int, + point: tuple[float, float, float], + a: tuple[float, float, float], + b: tuple[float, float, float], + c: tuple[float, float, float], + **metadata, +) -> dict[str, object]: + return { + "index": index, + "distance": math.sqrt( + _point_triangle_distance_squared(point, a, b, c) + ), + "normal": _normal(a, b, c), + "vertices": (a, b, c), + **metadata, + } + + +def _nearest_collision( + collision_bytes: bytes, + point: tuple[float, float, float], + radius: float, + limit: int, +) -> list[dict[str, object]]: + nearby: list[dict[str, object]] = [] + for index, record in enumerate( + COLLISION_RECORD.iter_unpack(collision_bytes) + ): + a = tuple(record[0:3]) + b = tuple(record[3:6]) + c = tuple(record[6:9]) + if not _within_expanded_bounds(point, radius, (a, b, c)): + continue + result = _triangle_result( + index, + point, + a, + b, + c, + surface=record[9], + material=record[10], + edge_codes=tuple(record[11:14]), + has_native_edge_codes=bool(record[14]), + ) + if result["distance"] <= radius: + nearby.append(result) + nearby.sort(key=lambda item: (item["distance"], item["index"])) + return nearby[:limit] + + +def _nearest_visual( + vertex_bytes: bytes, + indices: tuple[int, ...], + point: tuple[float, float, float], + radius: float, + limit: int, +) -> list[dict[str, object]]: + position_cache: dict[int, tuple[float, float, float]] = {} + + def position(index: int) -> tuple[float, float, float]: + value = position_cache.get(index) + if value is None: + value = tuple( + VISUAL_VERTEX.unpack_from( + vertex_bytes, index * VISUAL_VERTEX.size + )[0:3] + ) + position_cache[index] = value + return value + + nearby: list[dict[str, object]] = [] + for triangle_index in range(0, len(indices), 3): + vertex_indices = indices[triangle_index : triangle_index + 3] + if len(vertex_indices) != 3: + break + a, b, c = (position(index) for index in vertex_indices) + if not _within_expanded_bounds(point, radius, (a, b, c)): + continue + material = VISUAL_VERTEX.unpack_from( + vertex_bytes, vertex_indices[0] * VISUAL_VERTEX.size + )[10] + result = _triangle_result( + triangle_index // 3, + point, + a, + b, + c, + material=material, + vertex_indices=vertex_indices, + ) + if result["distance"] <= radius: + nearby.append(result) + nearby.sort(key=lambda item: (item["distance"], item["index"])) + return nearby[:limit] + + +def _source_matches( + source_root: Path, + manifest_path: Path, + collision_triangles: list[dict[str, object]], +) -> list[dict[str, object]]: + if not collision_triangles: + return [] + sys.path.insert(0, str(manifest_path.parent.parent.parent / "tools")) + from retail_collision_mesh import decode_rx2_clustered_meshes + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + cache_root = manifest_path.parent + wanted = { + tuple( + round(component, 6) + for vertex in triangle["vertices"] + for component in vertex + ) + for triangle in collision_triangles + } + matches: list[dict[str, object]] = [] + for asset in manifest["simulation_assets"]: + for mesh_metadata in asset.get("collision_meshes", []): + minimum = mesh_metadata["bounds"]["minimum"] + maximum = mesh_metadata["bounds"]["maximum"] + if not any( + all( + minimum[axis] - 0.01 <= vertex[axis] + <= maximum[axis] + 0.01 + for axis in range(3) + ) + for triangle in collision_triangles + for vertex in triangle["vertices"] + ): + continue + rx2 = cache_root / Path(str(asset["rx2"]).replace("\\", "/")) + meshes = decode_rx2_clustered_meshes(rx2.read_bytes()) + for mesh_index, mesh in enumerate(meshes): + for triangle_index, triangle in enumerate(mesh.triangles): + key = tuple( + round(component, 6) + for vertex in (triangle.a, triangle.b, triangle.c) + for component in vertex + ) + if key in wanted: + matches.append( + { + "asset_id": asset["asset_id"], + "stream_file": asset["stream_file"], + "rx2": str(rx2.relative_to(source_root)), + "mesh_index": mesh_index, + "triangle_index": triangle_index, + "cluster_index": ( + mesh.triangle_cluster_indices[ + triangle_index + ] + ), + "mesh_flags": mesh.mesh_flags, + "unit_flags": triangle.unit_flags, + "group_id": triangle.group_id, + "surface": triangle.surface, + "edge_codes": triangle.edge_codes, + "vertices": ( + triangle.a, + triangle.b, + triangle.c, + ), + } + ) + return matches + + +def main() -> int: + source_root = Path(__file__).resolve().parents[3] + workspace = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package", + type=Path, + default=workspace / "intermediate" / "university" / "University.skate", + ) + parser.add_argument( + "--manifest", + type=Path, + default=workspace / "intermediate" / "university" / "manifest.json", + ) + parser.add_argument("--point", nargs=3, type=float, required=True) + parser.add_argument("--radius", type=float, default=3.0) + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--skip-source", action="store_true") + args = parser.parse_args() + + analyzer = _load_analyzer(source_root) + package = analyzer.analyze_package( + args.package.resolve(), include_payloads=True + ) + point = tuple(args.point) + collision = _nearest_collision( + package["_collision_bytes"], point, args.radius, args.limit + ) + materials = { + material["id"]: material for material in package["_materials"] + } + for triangle in collision: + material = materials.get(triangle["material"]) + if material is None: + continue + triangle["material_name"] = material["name"] + triangle["retail_surface"] = ( + int(material["audio_surface"]) & 0x7F + ) | ((int(material["physics_surface"]) & 0x1F) << 7) | ( + (int(material["surface_pattern"]) & 0x0F) << 12 + ) + triangle["retail_surface_channels"] = { + "audio": material["audio_surface"], + "physics": material["physics_surface"], + "pattern": material["surface_pattern"], + } + visual = _nearest_visual( + package["_vertex_bytes"], + package["_indices"], + point, + args.radius, + args.limit, + ) + result = { + "format": "university-point-inspection-v1", + "package": str(args.package.resolve()), + "point": point, + "radius": args.radius, + "collision": collision, + "visual": visual, + "source_matches": ( + [] + if args.skip_source + else _source_matches( + source_root, args.manifest.resolve(), collision + ) + ), + } + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py new file mode 100644 index 00000000..f3f2d711 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_hawaiian_dream.py @@ -0,0 +1,982 @@ +"""Prepare lossless RX2 assets and a Blender-friendly Hawaiian Dream cache.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import struct +import sys + +import numpy + +from retail_collision_mesh import decode_rx2_clustered_meshes +from retail_grind_splines import decode_grind_splines +from retail_lightmap_uv import ( + decode_decal_uvs, + decode_lightmap_uvs, + decode_retail_world_frame, +) +from retail_texture_decode import ( + B5G6R5_DECODER_NAME, + B5G6R5_FORMAT_ID, + decode_b5g6r5, +) +from skate3_streams import ( + ASSET_TYPE_MODEL, + ASSET_TYPE_TEXTURE, + StreamAsset, + load_district_stream, +) + + +DISTRICT_NAME = "DIST_DHS 32221" +TEXTURE_NAME_PATTERN = re.compile(rb"(0x[0-9A-Fa-f]{16})\.Texture") +MATERIAL_TEXTURE_PATTERN = re.compile(r"(0x[0-9A-Fa-f]{16})$") +LAYERPAGE_TEXTURE_PATTERN = re.compile( + rb"(layerpage_[^\x00\r\n]+?)\.Texture" +) +CHROMATICITY_TEXTURE_PATTERN = re.compile( + rb"(chromo_[^\x00\r\n]+?)\.Texture" +) +ALPHA_BLEND_SHADERS = { + "environment.reflective_trans", + "environment.transparent", +} +RETAIL_TEXTURE_CHANNELS = ( + "diffuse", + "transparent", + "normal", + "normal2", + "specular", + "lightmap", + "chromaticity", + "detail", + "macrooverlay", + "decal", + "environment", + "noise", +) +RETAIL_WORLD_FRAME_SHADER_PREFIXES = ( + "environment.default", + "environment.decal", + "environment.reflective", +) +RX2_TOC_RECORD_SIZE = 24 +RX2_TYPE_MATERIAL = 0x00EB0005 +RX2_TYPE_EXTERNAL_REFERENCES = 0x00EB000B +RX2_TYPE_MESH_DESCRIPTOR = 0x00EB0023 +RX2_TYPE_MATERIAL_IMPORT = 0x00EB0066 + + +def _default_workspace() -> Path: + return Path(__file__).resolve().parents[1] + + +def _default_stream_directory(workspace: Path) -> Path: + return ( + workspace + / "raw" + / "hawaiian_dream" + / "extracted" + / "data" + / "content" + / "world" + / "stream" + / DISTRICT_NAME + ) + + +def _write_rx2(output_root: Path, category: str, asset: StreamAsset) -> Path: + target = output_root / "rx2" / category / f"{asset.record.asset_id:016X}.rx2" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(asset.data) + return target + + +def _texture_id(data: bytes, asset_id: int) -> str: + matches = TEXTURE_NAME_PATTERN.findall(data) + if matches: + return matches[-1].decode("ascii").lower() + layerpages = LAYERPAGE_TEXTURE_PATTERN.findall(data) + if layerpages: + return _symbolic_texture_id( + layerpages[-1].decode("ascii") + ) + chromaticity = CHROMATICITY_TEXTURE_PATTERN.findall(data) + if chromaticity: + return _symbolic_texture_id( + chromaticity[-1].decode("ascii") + ) + return f"asset_{asset_id:016x}" + + +def _symbolic_texture_id(name: str) -> str: + """Return a compact stable ID for a non-hashed retail texture name. + + Skate 2's baked layer and chromaticity pages are referenced by long + symbolic names instead of the trailing 64-bit IDs used by ordinary + retail textures. + Blender truncates long datablock names, so use a deterministic digest as + the interchange identity while preserving the source spelling in retail + parameters. + """ + + normalized = name.strip().removesuffix(".Texture").lower() + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:24] + return f"retail_symbolic_{digest}" + + +def _material_texture_id(material_name: str | None) -> str | None: + if not material_name: + return None + match = MATERIAL_TEXTURE_PATTERN.search(material_name) + if match: + return match.group(1).lower() + if material_name.lower().startswith(("layerpage_", "chromo_")): + return _symbolic_texture_id(material_name) + return None + + +def _group_material_parameters( + parameters: object, +) -> list[dict[str, list[str]]]: + """Recover the per-mesh material groups flattened by mdl_parser. + + A material starts at each ``Name`` parameter. Selecting the Nth value + from a global list of ``diffuse`` parameters is unsafe because materials + such as water have no diffuse channel; that shifts every later mesh onto + the wrong texture. + """ + groups: list[dict[str, list[str]]] = [] + current: dict[str, list[str]] | None = None + for parameter in parameters: + kind = str(parameter.kind) + value = str(parameter.value) + if kind == "Name": + current = {} + groups.append(current) + if current is not None: + current.setdefault(kind, []).append(value) + return groups + + +def _first_parameter( + group: dict[str, list[str]], + name: str, +) -> str | None: + values = group.get(name, []) + return values[0] if values and values[0] else None + + +def _bind_material_groups_by_guid( + data: bytes, + groups: list[dict[str, list[str]]], + mesh_count: int, + *, + allow_import_order_fallback: bool = False, +) -> tuple[list[dict[str, list[str]]], list[dict[str, object]]]: + """Resolve every mesh to its retail material through RX2 GUID handles. + + Material records are not stored in render-mesh order. Each ``Name`` + parameter carries a 64-bit GUID, the external-reference table associates + that GUID with a local handle, and each ``0x00EB0023`` mesh descriptor + stores the handle it draws with. Following that chain is authoritative + and also disambiguates repeated materials with different lightmaps. + """ + + if len(data) < 0x34: + raise ValueError("RX2 is too small to contain a section table") + file_count = struct.unpack_from(">I", data, 0x20)[0] + file_table = struct.unpack_from(">I", data, 0x30)[0] + if file_table + file_count * RX2_TOC_RECORD_SIZE > len(data): + raise ValueError("RX2 section table extends beyond the file") + + records: list[tuple[int, int, int]] = [] + for record_index in range(file_count): + record_offset = file_table + record_index * RX2_TOC_RECORD_SIZE + section_offset, _, section_size, _, _ = struct.unpack_from( + ">5I", + data, + record_offset, + ) + section_type = struct.unpack_from(">I", data, record_offset + 20)[0] + records.append((section_type, section_offset, section_size)) + + material_guids: list[int] = [] + for section_type, section_offset, _ in records: + if section_type != RX2_TYPE_MATERIAL: + continue + header = struct.unpack_from(">8I", data, section_offset) + parameter_count = header[1] + header_size = header[3] + parameters_size = header[4] + if parameter_count == 0: + continue + payload_size = parameters_size - header_size + if payload_size < 0 or payload_size % parameter_count: + raise ValueError("RX2 material parameter table has invalid sizes") + block_size = payload_size // parameter_count + if block_size != 32: + raise ValueError( + f"RX2 material GUID binding requires 32-byte records, got {block_size}" + ) + for parameter_index in range(parameter_count): + parameter_offset = ( + section_offset + header_size + parameter_index * block_size + ) + values = struct.unpack_from(">8I", data, parameter_offset) + kind_offset = section_offset + values[0] + kind_end = data.index(b"\0", kind_offset) + if data[kind_offset:kind_end] == b"Name": + material_guids.append((values[4] << 32) | values[5]) + + if len(material_guids) != len(groups): + raise ValueError( + "RX2 material GUID count does not match parsed material groups: " + f"{len(material_guids)} versus {len(groups)}" + ) + + imports: list[tuple[int, int]] = [] + for section_type, section_offset, _ in records: + if section_type != RX2_TYPE_EXTERNAL_REFERENCES: + continue + import_count, import_offset = struct.unpack_from( + ">2I", + data, + section_offset, + ) + for import_index in range(import_count): + entry_offset = section_offset + import_offset + import_index * 24 + _, _, guid_high, guid_low, import_type, handle = struct.unpack_from( + ">6I", + data, + entry_offset, + ) + if import_type == RX2_TYPE_MATERIAL_IMPORT: + imports.append(((guid_high << 32) | guid_low, handle)) + + handle_groups: dict[int, tuple[int, int, int, str]] = {} + import_cursor = 0 + guid_binding_failed = False + for group_index, material_guid in enumerate(material_guids): + while ( + import_cursor < len(imports) + and imports[import_cursor][0] != material_guid + ): + import_cursor += 1 + if import_cursor >= len(imports): + guid_binding_failed = True + break + external_guid, handle = imports[import_cursor] + handle_groups[handle] = ( + group_index, + material_guid, + external_guid, + "external_reference_guid", + ) + import_cursor += 1 + + if guid_binding_failed: + if not allow_import_order_fallback or len(imports) != len(material_guids): + raise ValueError( + "RX2 material GUID is missing from the external-reference table: " + f"0x{material_guids[len(handle_groups)]:016X}" + ) + # Skate 2 sometimes uses a different GUID namespace for a material's + # Name parameter and its external reference. The tables still have a + # one-to-one order, and mesh descriptors continue to reference the + # imported handles. Keep both GUIDs so the mismatch remains visible. + handle_groups.clear() + for group_index, (material_guid, imported) in enumerate( + zip(material_guids, imports) + ): + external_guid, handle = imported + handle_groups[handle] = ( + group_index, + material_guid, + external_guid, + "external_reference_order", + ) + + bound_groups: list[dict[str, list[str]]] = [] + bindings: list[dict[str, object]] = [] + for section_type, section_offset, _ in records: + if section_type != RX2_TYPE_MESH_DESCRIPTOR: + continue + handle = struct.unpack_from(">I", data, section_offset + 0x24)[0] + if handle not in handle_groups: + raise ValueError( + "RX2 mesh material handle is unresolved: " + f"0x{handle:08X}" + ) + group_index, material_guid, external_guid, strategy = handle_groups[handle] + bound_groups.append(groups[group_index]) + bindings.append( + { + "group_index": group_index, + "material_guid": material_guid, + "external_reference_guid": external_guid, + "material_handle": handle, + "strategy": strategy, + } + ) + + if len(bound_groups) != mesh_count: + raise ValueError( + "RX2 mesh descriptor count does not match parsed render meshes: " + f"{len(bound_groups)} versus {mesh_count}" + ) + return bound_groups, bindings + + +def _material_metadata( + groups: list[dict[str, list[str]]], + mesh_index: int, +) -> dict[str, object]: + group = groups[mesh_index] if mesh_index < len(groups) else {} + diffuse = _first_parameter(group, "diffuse") + transparent = _first_parameter(group, "transparent") + shader_name = _first_parameter(group, "AttribulatorMaterialName") + material_name = diffuse or transparent + shader_key = (shader_name or "").lower() + if shader_key in ALPHA_BLEND_SHADERS: + alpha_mode = 2 + elif ( + transparent is not None + or "alphatest" in shader_key + or shader_key in {"animated.tree", "tree.default"} + ): + alpha_mode = 1 + else: + alpha_mode = 0 + retail_texture_ids = { + channel: texture_id + for channel in RETAIL_TEXTURE_CHANNELS + if ( + texture_id := _material_texture_id( + _first_parameter(group, channel) + ) + ) + is not None + } + return { + "material_name": material_name, + "texture_id": _material_texture_id(material_name), + "texture_channel": ( + "diffuse" + if diffuse is not None + else "transparent" + if transparent is not None + else None + ), + "shader_name": shader_name, + "alpha_mode": alpha_mode, + "alpha_cutoff": 0.5, + # Preserve every material channel, including constants and fields the + # current renderer does not understand yet. Values remain in parser + # order as strings so GUIDs and packed numeric spellings round-trip + # without type guessing. + "retail_parameters": { + name: list(values) + for name, values in group.items() + }, + # Preserve every retail texture association in the extraction + # manifest even when the current Blender/export policy does not yet + # consume that role. This keeps extraction lossless and makes later + # fidelity work independent of another RX2 material reparse. + "retail_texture_ids": retail_texture_ids, + } + + +def prepare( + stream_directory: Path, + output_root: Path, + utt_root: Path, + *, + district_name: str = DISTRICT_NAME, + map_name: str = "Danny Way's Hawaiian Dream", + package_name: str = "DHS by DH13", + cache_format: str = "skate3-hawaiian-dream-cache-v1", + texture_stream_names: tuple[str, ...] = (), + excluded_normal_texture_ids: tuple[str, ...] = (), + allow_material_import_order_fallback: bool = False, + grind_coordinate_mode: str = "world_space", + excluded_static_model_asset_ids: tuple[str, ...] = (), +) -> Path: + sys.path.insert(0, str(utt_root)) + import rx2_parser + from mdl_parser import parse_rx2 as parse_model + + output_root.mkdir(parents=True, exist_ok=True) + presentation_assets = load_district_stream( + stream_directory, + "Pres", + district_name, + ) + simulation_assets = load_district_stream( + stream_directory, + "Sim", + district_name, + ) + extra_texture_assets = [ + asset + for stream_name in texture_stream_names + for asset in load_district_stream( + stream_directory, + stream_name, + district_name, + ) + ] + + manifest: dict[str, object] = { + "format": cache_format, + "map_name": map_name, + "package_name": package_name, + "district_name": district_name, + "source_stream_directory": str(stream_directory), + "axis_conversion": { + "runtime_to_blender": ["x", "-z", "y"], + "uv_v_flipped": True, + }, + "normal_texture_policy": { + "excluded_texture_ids": sorted( + texture_id.lower() + for texture_id in excluded_normal_texture_ids + ), + }, + "grind_coordinate_policy": { + "mode": grind_coordinate_mode, + "classification_margin": 40.0, + }, + "presentation_model_policy": { + "excluded_asset_ids": sorted( + asset_id.lower() + for asset_id in excluded_static_model_asset_ids + ), + }, + "textures": {}, + "models": [], + "simulation_assets": [], + "grind_splines": [], + "other_presentation_assets": [], + } + textures: dict[str, object] = manifest["textures"] # type: ignore[assignment] + models: list[object] = manifest["models"] # type: ignore[assignment] + simulation: list[object] = manifest["simulation_assets"] # type: ignore[assignment] + grind_splines: list[object] = manifest["grind_splines"] # type: ignore[assignment] + other: list[object] = manifest["other_presentation_assets"] # type: ignore[assignment] + texture_digests: dict[str, set[bytes]] = {} + + for asset in presentation_assets + extra_texture_assets: + asset_id = asset.record.asset_id + source_file = asset.source_path.name + if asset.record.asset_type == ASSET_TYPE_TEXTURE: + rx2_path = _write_rx2(output_root, "textures", asset) + base_texture_id = _texture_id(asset.data, asset_id) + parsed = rx2_parser.parse_rx2(asset.data) + if not parsed.textures: + raise RuntimeError( + f"texture asset 0x{asset_id:016X} contains no decoded texture" + ) + for texture_index, texture in enumerate(parsed.textures): + texture_decoder = None + if texture.fmt_id == B5G6R5_FORMAT_ID: + raw_texture = parsed.data[ + texture.data_offset : + texture.data_offset + texture.buffer_size + ] + texture.rgba = decode_b5g6r5( + raw_texture, + texture.width, + texture.height, + ) + texture_decoder = B5G6R5_DECODER_NAME + texture_id = ( + base_texture_id + if texture_index == 0 + else f"{base_texture_id}_{texture_index}" + ) + png_path = ( + output_root + / "textures" + / "by_asset" + / f"{asset_id:016X}_{texture_index}.png" + ) + png_path.parent.mkdir(parents=True, exist_ok=True) + texture.save_png(png_path) + new_entry = { + "stream_asset_id": f"0x{asset_id:016X}", + "stream_file": source_file, + "rx2": str(rx2_path.relative_to(output_root)), + "png": str(png_path.relative_to(output_root)), + "texture_index": texture_index, + "width": texture.width, + "height": texture.height, + "format": texture.fmt_name, + "warnings": list(parsed.warnings), + } + if texture_decoder is not None: + new_entry["decoder"] = texture_decoder + texture_digest = hashlib.sha256(texture.rgba).digest() + known_digests = texture_digests.setdefault(texture_id, set()) + if texture_id in textures: + existing = textures[texture_id] + if texture_digest in known_digests: + existing.setdefault("alternate_stream_assets", []).append( # type: ignore[union-attr] + new_entry + ) + elif texture.width * texture.height > ( # type: ignore[index] + existing["width"] * existing["height"] # type: ignore[index,operator] + ): + previous_entry = { + key: value + for key, value in existing.items() # type: ignore[union-attr] + if key not in {"variants", "alternate_stream_assets"} + } + new_entry["variants"] = list( # type: ignore[index] + existing.get("variants", []) # type: ignore[union-attr] + ) + [previous_entry] + if existing.get("alternate_stream_assets"): # type: ignore[union-attr] + new_entry["alternate_stream_assets"] = existing[ # type: ignore[index] + "alternate_stream_assets" + ] + textures[texture_id] = new_entry + else: + existing.setdefault("variants", []).append(new_entry) # type: ignore[union-attr] + known_digests.add(texture_digest) + continue + + known_digests.add(texture_digest) + textures[texture_id] = new_entry + elif asset.record.asset_type == ASSET_TYPE_MODEL: + rx2_path = _write_rx2(output_root, "models", asset) + parsed = parse_model( + asset.data, + source_path=rx2_path, + strict=False, + ) + npz_path = output_root / "models" / f"{asset_id:016X}.npz" + npz_path.parent.mkdir(parents=True, exist_ok=True) + arrays: dict[str, numpy.ndarray] = {} + mesh_entries: list[dict[str, object]] = [] + source_material_groups = _group_material_parameters(parsed.materials) + material_groups, material_bindings = ( + _bind_material_groups_by_guid( + asset.data, + source_material_groups, + len(parsed.meshes), + allow_import_order_fallback=( + allow_material_import_order_fallback + ), + ) + ) + for mesh_index, mesh in enumerate(parsed.meshes): + material = _material_metadata(material_groups, mesh_index) + material_group = ( + material_groups[mesh_index] + if mesh_index < len(material_groups) + else {} + ) + material_binding = material_bindings[mesh_index] + arrays[f"vertices_{mesh_index}"] = numpy.asarray( + mesh.vertices, + dtype=numpy.float32, + ) + arrays[f"faces_{mesh_index}"] = numpy.asarray( + mesh.faces, + dtype=numpy.uint32, + ) + if mesh.uvs is not None: + arrays[f"uvs_{mesh_index}"] = numpy.asarray( + mesh.uvs, + dtype=numpy.float32, + ) + lightmap_uv = decode_lightmap_uvs( + asset.data, + vertex_buffer_offset=mesh.source_offsets[ + "vertex_buffer" + ], + vertex_count=mesh.vertex_count, + vertex_stride=mesh.vertex_stride, + attributes=mesh.attributes, + ) + if lightmap_uv is not None: + arrays[f"lightmap_uvs_{mesh_index}"] = ( + lightmap_uv.values + ) + shader_name = str(material.get("shader_name") or "") + retail_world_frame = ( + decode_retail_world_frame( + asset.data, + vertex_buffer_offset=mesh.source_offsets[ + "vertex_buffer" + ], + vertex_count=mesh.vertex_count, + vertex_stride=mesh.vertex_stride, + attributes=mesh.attributes, + ) + if shader_name.startswith( + RETAIL_WORLD_FRAME_SHADER_PREFIXES + ) + else None + ) + if retail_world_frame is not None: + arrays[f"retail_normals_{mesh_index}"] = ( + retail_world_frame.normals + ) + arrays[f"retail_tangents_{mesh_index}"] = ( + retail_world_frame.tangents + ) + arrays[f"retail_tangent_handedness_{mesh_index}"] = ( + retail_world_frame.tangent_handedness + ) + decal_uv = decode_decal_uvs( + asset.data, + vertex_buffer_offset=mesh.source_offsets[ + "vertex_buffer" + ], + vertex_count=mesh.vertex_count, + vertex_stride=mesh.vertex_stride, + attributes=mesh.attributes, + ) + if decal_uv is not None: + arrays[f"decal_uvs_{mesh_index}"] = decal_uv.values + if mesh.normals is not None: + arrays[f"normals_{mesh_index}"] = numpy.asarray( + mesh.normals, + dtype=numpy.float32, + ) + minimum, maximum = mesh.bounds + mesh_entries.append( + { + "index": mesh_index, + "name": ( + _first_parameter(material_group, "Name") + or mesh.name + ), + "retail_material_guid": ( + f"0x{material_binding['material_guid']:016X}" + ), + "retail_external_reference_guid": ( + "0x" + f"{material_binding['external_reference_guid']:016X}" + ), + "retail_material_handle": ( + f"0x{material_binding['material_handle']:08X}" + ), + "retail_material_group_index": material_binding[ + "group_index" + ], + **material, + "vertex_count": mesh.vertex_count, + "triangle_count": mesh.triangle_count, + "vertex_stride": mesh.vertex_stride, + "attributes": [ + { + "semantic": attribute.semantic, + "offset": attribute.offset, + "data_type": attribute.data_type, + "components": attribute.components, + "descriptor": attribute.descriptor.hex(), + } + for attribute in mesh.attributes + ], + "lightmap_uv": ( + { + "format_code": ( + f"0x{lightmap_uv.format_code:08X}" + ), + "offset": lightmap_uv.offset, + "usage_index": lightmap_uv.usage_index, + "signed_tangent_handedness": True, + } + if lightmap_uv is not None + else None + ), + "retail_world_frame": ( + { + "normal_source": ( + "secondary_texcoord_zw_and_y_sign" + ), + "tangent_format_code": ( + f"0x{retail_world_frame.tangent_format_code:08X}" + ), + "tangent_offset": ( + retail_world_frame.tangent_offset + ), + "tangent_handedness_source": ( + "secondary_texcoord_x_sign" + ), + } + if retail_world_frame is not None + else None + ), + "decal_uv": ( + { + "format_code": ( + f"0x{decal_uv.format_code:08X}" + ), + "offset": decal_uv.offset, + "usage_index": decal_uv.usage_index, + } + if decal_uv is not None + else None + ), + "source_offsets": dict(mesh.source_offsets), + "bounds": { + "minimum": minimum.tolist(), + "maximum": maximum.tolist(), + }, + } + ) + numpy.savez_compressed(npz_path, **arrays) + minimum, maximum = parsed.bounds + models.append( + { + "asset_id": f"0x{asset_id:016X}", + "stream_file": source_file, + "source_offset": asset.source_offset, + "rx2": str(rx2_path.relative_to(output_root)), + "npz": str(npz_path.relative_to(output_root)), + "bounds": { + "minimum": minimum.tolist(), + "maximum": maximum.tolist(), + }, + "vertex_count": parsed.vertex_count, + "triangle_count": parsed.triangle_count, + "warnings": list(parsed.warnings), + "material_binding": { + "strategy": ( + "external_reference_order" + if any( + binding["strategy"] + == "external_reference_order" + for binding in material_bindings + ) + else "external_reference_guid" + ), + "source_group_count": len(source_material_groups), + "selected_group_count": len(material_groups), + "mesh_count": len(parsed.meshes), + }, + "meshes": mesh_entries, + } + ) + else: + rx2_path = _write_rx2(output_root, "presentation_other", asset) + other.append( + { + "asset_id": f"0x{asset_id:016X}", + "asset_type": f"0x{asset.record.asset_type:08X}", + "stream_file": source_file, + "source_offset": asset.source_offset, + "rx2": str(rx2_path.relative_to(output_root)), + } + ) + + grind_spline_asset_count = 0 + collision_mesh_asset_count = 0 + collision_mesh_count = 0 + collision_cluster_count = 0 + collision_triangle_count = 0 + for asset in simulation_assets: + rx2_path = _write_rx2(output_root, "simulation", asset) + asset_grinds = decode_grind_splines(asset.data) + asset_collision_meshes = decode_rx2_clustered_meshes(asset.data) + grind_spline_asset_count += bool(asset_grinds) + collision_mesh_asset_count += bool(asset_collision_meshes) + collision_mesh_count += len(asset_collision_meshes) + for rail in asset_grinds: + grind_splines.append( + { + "asset_id": f"0x{asset.record.asset_id:016X}", + "stream_file": asset.source_path.name, + **rail, + } + ) + collision_entries = [] + for mesh_index, mesh in enumerate(asset_collision_meshes): + triangle_count = len(mesh.triangles) + collision_cluster_count += mesh.cluster_count + collision_triangle_count += triangle_count + collision_entries.append( + { + "index": mesh_index, + "bounds": { + "minimum": mesh.bounds_min, + "maximum": mesh.bounds_max, + }, + "triangles": triangle_count, + "clusters": mesh.cluster_count, + "vertices": mesh.vertex_count, + "units": mesh.unit_count, + "compression_cluster_counts": { + str(compression): count + for compression, count in mesh.compression_counts + }, + } + ) + simulation.append( + { + "asset_id": f"0x{asset.record.asset_id:016X}", + "asset_type": f"0x{asset.record.asset_type:08X}", + "stream_file": asset.source_path.name, + "source_offset": asset.source_offset, + "size": len(asset.data), + "rx2": str(rx2_path.relative_to(output_root)), + "collision_meshes": collision_entries, + } + ) + + used_texture_ids = { + mesh["texture_id"] + for model in models + for mesh in model["meshes"] # type: ignore[index] + if mesh["texture_id"] is not None + } + used_texture_ids_by_channel = { + channel: { + texture_id + for model in models + for mesh in model["meshes"] # type: ignore[index] + if ( + texture_id := mesh["retail_texture_ids"].get(channel) # type: ignore[union-attr] + ) + is not None + } + for channel in RETAIL_TEXTURE_CHANNELS + } + manifest["summary"] = { + "presentation_assets": len(presentation_assets), + "model_assets": len(models), + "mesh_parts": sum(len(model["meshes"]) for model in models), # type: ignore[arg-type,index] + "excluded_static_mesh_parts": sum( + len(model["meshes"]) # type: ignore[arg-type,index] + for model in models + if str(model["asset_id"]).lower() # type: ignore[index] + in { + asset_id.lower() + for asset_id in excluded_static_model_asset_ids + } + ), + "vertices": sum(model["vertex_count"] for model in models), # type: ignore[arg-type] + "triangles": sum(model["triangle_count"] for model in models), # type: ignore[arg-type] + "texture_stream_assets": sum( + asset.record.asset_type == ASSET_TYPE_TEXTURE + for asset in presentation_assets + extra_texture_assets + ), + "decoded_textures": len(textures), + "used_diffuse_textures": len(used_texture_ids), + "bound_texture_references_by_channel": { + channel: sum( + channel in mesh["retail_texture_ids"] # type: ignore[operator] + for model in models + for mesh in model["meshes"] # type: ignore[index] + ) + for channel in RETAIL_TEXTURE_CHANNELS + }, + "used_texture_ids_by_channel": { + channel: len(texture_ids) + for channel, texture_ids in used_texture_ids_by_channel.items() + }, + "secondary_uv_mesh_parts": sum( + mesh["lightmap_uv"] is not None # type: ignore[index] + for model in models + for mesh in model["meshes"] # type: ignore[index] + ), + "secondary_uv_format_counts": { + format_code: sum( + mesh["lightmap_uv"] is not None # type: ignore[index] + and mesh["lightmap_uv"]["format_code"] == format_code # type: ignore[index] + for model in models + for mesh in model["meshes"] # type: ignore[index] + ) + for format_code in sorted( + { + mesh["lightmap_uv"]["format_code"] # type: ignore[index] + for model in models + for mesh in model["meshes"] # type: ignore[index] + if mesh["lightmap_uv"] is not None # type: ignore[index] + } + ) + }, + "material_binding_strategies": { + strategy: sum( + model["material_binding"]["strategy"] == strategy # type: ignore[index] + for model in models + ) + for strategy in { + model["material_binding"]["strategy"] # type: ignore[index] + for model in models + } + }, + "simulation_assets": len(simulation_assets), + "collision_mesh_assets": collision_mesh_asset_count, + "collision_meshes": collision_mesh_count, + "collision_clusters": collision_cluster_count, + "collision_triangles": collision_triangle_count, + "grind_spline_assets": grind_spline_asset_count, + "grind_rails": len(grind_splines), + "grind_segments": sum( + rail["segment_count"] for rail in grind_splines # type: ignore[index] + ), + "closed_grind_rails": sum( + bool(rail["closed"]) for rail in grind_splines # type: ignore[index] + ), + "unmatched_diffuse_textures": sorted(used_texture_ids - set(textures)), + "unmatched_texture_ids_by_channel": { + channel: sorted(texture_ids - set(textures)) + for channel, texture_ids in used_texture_ids_by_channel.items() + }, + } + + manifest_path = output_root / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return manifest_path + + +def main(argv: list[str] | None = None) -> int: + workspace = _default_workspace() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stream-dir", + type=Path, + default=_default_stream_directory(workspace), + ) + parser.add_argument( + "--output", + type=Path, + default=workspace / "intermediate" / "hawaiian_dream", + ) + parser.add_argument( + "--utt-root", + type=Path, + default=( + Path(os.environ["SKATE3_UTT_ROOT"]) + if os.environ.get("SKATE3_UTT_ROOT") + else None + ), + required=not bool(os.environ.get("SKATE3_UTT_ROOT")), + help=( + "Path to UTT-1.1.7 (or set the SKATE3_UTT_ROOT environment " + "variable)" + ), + ) + args = parser.parse_args(argv) + manifest_path = prepare( + stream_directory=args.stream_dir.resolve(), + output_root=args.output.resolve(), + utt_root=args.utt_root.resolve(), + ) + print(manifest_path) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + print(json.dumps(manifest["summary"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_mega_park.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_mega_park.py new file mode 100644 index 00000000..2fff70ad --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_mega_park.py @@ -0,0 +1,277 @@ +"""Prepare lossless RX2 assets and a Blender-friendly Mega Park cache.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +import numpy + +from skate3_streams import ( + ASSET_TYPE_MODEL, + ASSET_TYPE_SIMULATION, + ASSET_TYPE_TEXTURE, + StreamAsset, + load_global_stream, +) + + +TEXTURE_NAME_PATTERN = re.compile(rb"(0x[0-9A-Fa-f]{16})\.Texture") +MATERIAL_TEXTURE_PATTERN = re.compile(r"(0x[0-9A-Fa-f]{16})$") + + +def _default_workspace() -> Path: + return Path(__file__).resolve().parents[1] + + +def _default_stream_directory(workspace: Path) -> Path: + return ( + workspace + / "raw" + / "mega_park" + / "data" + / "content" + / "world" + / "stream" + / "DIST_MegaPark" + ) + + +def _write_rx2(output_root: Path, category: str, asset: StreamAsset) -> Path: + target = output_root / "rx2" / category / f"{asset.record.asset_id:016X}.rx2" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(asset.data) + return target + + +def _texture_id(data: bytes, asset_id: int) -> str: + matches = TEXTURE_NAME_PATTERN.findall(data) + if matches: + return matches[-1].decode("ascii").lower() + return f"asset_{asset_id:016x}" + + +def _material_texture_id(material_name: str | None) -> str | None: + if not material_name: + return None + match = MATERIAL_TEXTURE_PATTERN.search(material_name) + return match.group(1).lower() if match else None + + +def prepare( + workspace: Path, + stream_directory: Path, + output_root: Path, + utt_root: Path, +) -> Path: + sys.path.insert(0, str(utt_root)) + import rx2_parser + from mdl_parser import parse_rx2 as parse_model + + output_root.mkdir(parents=True, exist_ok=True) + presentation_assets = load_global_stream(stream_directory, "Pres") + simulation_assets = load_global_stream(stream_directory, "Sim") + + manifest: dict[str, object] = { + "format": "skate3-mega-park-cache-v1", + "source_stream_directory": str(stream_directory), + "axis_conversion": { + "runtime_to_blender": ["x", "-z", "y"], + "uv_v_flipped": True, + }, + "textures": {}, + "models": [], + "simulation_assets": [], + "other_presentation_assets": [], + } + textures: dict[str, object] = manifest["textures"] # type: ignore[assignment] + models: list[object] = manifest["models"] # type: ignore[assignment] + simulation: list[object] = manifest["simulation_assets"] # type: ignore[assignment] + other: list[object] = manifest["other_presentation_assets"] # type: ignore[assignment] + + for asset in presentation_assets: + asset_id = asset.record.asset_id + if asset.record.asset_type == ASSET_TYPE_TEXTURE: + rx2_path = _write_rx2(output_root, "textures", asset) + texture_id = _texture_id(asset.data, asset_id) + parsed = rx2_parser.parse_rx2(asset.data) + if not parsed.textures: + raise RuntimeError( + f"texture asset 0x{asset_id:016X} contains no decoded texture" + ) + texture = parsed.textures[0] + png_path = output_root / "textures" / f"{texture_id}.png" + png_path.parent.mkdir(parents=True, exist_ok=True) + texture.save_png(png_path) + textures[texture_id] = { + "stream_asset_id": f"0x{asset_id:016X}", + "rx2": str(rx2_path.relative_to(output_root)), + "png": str(png_path.relative_to(output_root)), + "width": texture.width, + "height": texture.height, + "format": texture.fmt_name, + "warnings": list(parsed.warnings), + } + elif asset.record.asset_type == ASSET_TYPE_MODEL: + rx2_path = _write_rx2(output_root, "models", asset) + parsed = parse_model( + asset.data, + source_path=rx2_path, + strict=False, + ) + npz_path = output_root / "models" / f"{asset_id:016X}.npz" + npz_path.parent.mkdir(parents=True, exist_ok=True) + arrays: dict[str, numpy.ndarray] = {} + mesh_entries: list[dict[str, object]] = [] + for mesh_index, mesh in enumerate(parsed.meshes): + arrays[f"vertices_{mesh_index}"] = numpy.asarray( + mesh.vertices, dtype=numpy.float32 + ) + arrays[f"faces_{mesh_index}"] = numpy.asarray( + mesh.faces, dtype=numpy.uint32 + ) + if mesh.uvs is not None: + arrays[f"uvs_{mesh_index}"] = numpy.asarray( + mesh.uvs, dtype=numpy.float32 + ) + if mesh.normals is not None: + arrays[f"normals_{mesh_index}"] = numpy.asarray( + mesh.normals, dtype=numpy.float32 + ) + minimum, maximum = mesh.bounds + mesh_entries.append( + { + "index": mesh_index, + "name": mesh.name, + "material_name": mesh.material_name, + "texture_id": _material_texture_id(mesh.material_name), + "vertex_count": mesh.vertex_count, + "triangle_count": mesh.triangle_count, + "vertex_stride": mesh.vertex_stride, + "attributes": [ + { + "semantic": attribute.semantic, + "offset": attribute.offset, + "data_type": attribute.data_type, + "components": attribute.components, + "descriptor": attribute.descriptor.hex(), + } + for attribute in mesh.attributes + ], + "source_offsets": dict(mesh.source_offsets), + "bounds": { + "minimum": minimum.tolist(), + "maximum": maximum.tolist(), + }, + } + ) + numpy.savez_compressed(npz_path, **arrays) + minimum, maximum = parsed.bounds + models.append( + { + "asset_id": f"0x{asset_id:016X}", + "source_offset": asset.source_offset, + "rx2": str(rx2_path.relative_to(output_root)), + "npz": str(npz_path.relative_to(output_root)), + "bounds": { + "minimum": minimum.tolist(), + "maximum": maximum.tolist(), + }, + "vertex_count": parsed.vertex_count, + "triangle_count": parsed.triangle_count, + "warnings": list(parsed.warnings), + "meshes": mesh_entries, + } + ) + else: + rx2_path = _write_rx2(output_root, "presentation_other", asset) + other.append( + { + "asset_id": f"0x{asset_id:016X}", + "asset_type": f"0x{asset.record.asset_type:08X}", + "source_offset": asset.source_offset, + "rx2": str(rx2_path.relative_to(output_root)), + } + ) + + for asset in simulation_assets: + rx2_path = _write_rx2(output_root, "simulation", asset) + simulation.append( + { + "asset_id": f"0x{asset.record.asset_id:016X}", + "asset_type": f"0x{asset.record.asset_type:08X}", + "source_offset": asset.source_offset, + "size": len(asset.data), + "rx2": str(rx2_path.relative_to(output_root)), + } + ) + + used_texture_ids = { + mesh["texture_id"] + for model in models + for mesh in model["meshes"] # type: ignore[index] + if mesh["texture_id"] is not None + } + manifest["summary"] = { + "presentation_assets": len(presentation_assets), + "model_assets": len(models), + "mesh_parts": sum(len(model["meshes"]) for model in models), # type: ignore[arg-type,index] + "vertices": sum(model["vertex_count"] for model in models), # type: ignore[arg-type] + "triangles": sum(model["triangle_count"] for model in models), # type: ignore[arg-type] + "texture_assets": len(textures), + "used_diffuse_textures": len(used_texture_ids), + "simulation_assets": len(simulation_assets), + "unmatched_diffuse_textures": sorted(used_texture_ids - set(textures)), + } + + manifest_path = output_root / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return manifest_path + + +def main(argv: list[str] | None = None) -> int: + workspace = _default_workspace() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stream-dir", + type=Path, + default=_default_stream_directory(workspace), + ) + parser.add_argument( + "--output", + type=Path, + default=workspace / "intermediate" / "mega_park", + ) + parser.add_argument( + "--utt-root", + type=Path, + default=( + Path(os.environ["SKATE3_UTT_ROOT"]) + if os.environ.get("SKATE3_UTT_ROOT") + else None + ), + required=not bool(os.environ.get("SKATE3_UTT_ROOT")), + help=( + "Path to UTT-1.1.7 (or set the SKATE3_UTT_ROOT environment " + "variable)" + ), + ) + args = parser.parse_args(argv) + manifest_path = prepare( + workspace=workspace, + stream_directory=args.stream_dir.resolve(), + output_root=args.output.resolve(), + utt_root=args.utt_root.resolve(), + ) + print(manifest_path) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + print(json.dumps(manifest["summary"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_skate2_bam.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_skate2_bam.py new file mode 100644 index 00000000..e3d92003 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_skate2_bam.py @@ -0,0 +1,91 @@ +"""Prepare Skate 2's main New San Vanelona world stream for Blender.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +from prepare_hawaiian_dream import prepare + + +DISTRICT_NAME = "BAM" +EXCLUDED_NORMAL_TEXTURE_IDS = ( + # Referenced by Skate 2 materials but absent from every BAM texture stream. + # Preserve the material reference in the manifest while leaving Blender's + # generic tangent-space normal slot unbound. + "0x00005fbe03e3870a", +) +EXCLUDED_STATIC_MODEL_ASSET_IDS = ( + # Sign_35 is an unplaced prop template. Its gameplay placement data is not + # part of the static presentation model, so importing it at identity leaves + # a floating speed-limit sign at the world origin. + "0xD4538D2E1DA5434F", +) + + +def _workspace() -> Path: + return Path(__file__).resolve().parents[1] + + +def main() -> int: + workspace = _workspace() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stream-dir", + type=Path, + default=( + workspace + / "raw" + / "skate2" + / "worldbam-extracted" + / "data" + / "content" + / "world" + / "stream" + / DISTRICT_NAME + ), + ) + parser.add_argument( + "--output", + type=Path, + default=workspace / "intermediate" / "skate2_bam", + ) + parser.add_argument( + "--utt-root", + type=Path, + default=( + Path(os.environ["SKATE3_UTT_ROOT"]) + if os.environ.get("SKATE3_UTT_ROOT") + else None + ), + required=not bool(os.environ.get("SKATE3_UTT_ROOT")), + help=( + "Path to UTT-1.1.7 (or set the SKATE3_UTT_ROOT environment " + "variable)" + ), + ) + args = parser.parse_args() + manifest_path = prepare( + stream_directory=args.stream_dir.resolve(), + output_root=args.output.resolve(), + utt_root=args.utt_root.resolve(), + district_name=DISTRICT_NAME, + map_name="Skate 2 — New San Vanelona", + package_name="Official Skate 2 base game", + cache_format="skate2-bam-cache-v1", + texture_stream_names=("Tex",), + excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS, + allow_material_import_order_fallback=True, + grind_coordinate_mode="mixed_cell_local", + excluded_static_model_asset_ids=EXCLUDED_STATIC_MODEL_ASSET_IDS, + ) + print(manifest_path) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + print(json.dumps(manifest["summary"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_university.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_university.py new file mode 100644 index 00000000..9030c789 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/prepare_university.py @@ -0,0 +1,90 @@ +"""Prepare the official Skate 3 University district for Blender.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +from prepare_hawaiian_dream import prepare + + +DISTRICT_NAME = "DIST_University" +EXCLUDED_NORMAL_TEXTURE_IDS = ( + # The retail shaders treat these resources specially. The two decoded + # neutral defaults do not promote a retail simple material to the normal + # mapped path. The water pair uses a shader-specific/PCA path, and the palm + # textures are not conventional tangent-space normal maps. Retain all IDs + # in the manifest, but do not bind them to the generic owned-world normal + # slot. + "0x0000043d03e3870a", + "0x0000475d03e3870a", + "0x2c70170a00171210", + "0x2c70170a00171211", + "0x0000676403e3870a", + "0x0000676703e3870a", + "0x0000677003e3870a", +) + + +def _workspace() -> Path: + return Path(__file__).resolve().parents[1] + + +def main() -> int: + workspace = _workspace() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stream-dir", + type=Path, + default=( + workspace + / "raw" + / "university" + / "data" + / "content" + / "world" + / "stream" + / DISTRICT_NAME + ), + ) + parser.add_argument( + "--output", + type=Path, + default=workspace / "intermediate" / "university", + ) + parser.add_argument( + "--utt-root", + type=Path, + default=( + Path(os.environ["SKATE3_UTT_ROOT"]) + if os.environ.get("SKATE3_UTT_ROOT") + else None + ), + required=not bool(os.environ.get("SKATE3_UTT_ROOT")), + help=( + "Path to UTT-1.1.7 (or set the SKATE3_UTT_ROOT environment " + "variable)" + ), + ) + args = parser.parse_args() + manifest_path = prepare( + stream_directory=args.stream_dir.resolve(), + output_root=args.output.resolve(), + utt_root=args.utt_root.resolve(), + district_name=DISTRICT_NAME, + map_name="University District", + package_name="Official Skate 3 base game", + cache_format="skate3-university-cache-v1", + texture_stream_names=("Tex",), + excluded_normal_texture_ids=EXCLUDED_NORMAL_TEXTURE_IDS, + ) + print(manifest_path) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + print(json.dumps(manifest["summary"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_collision_mesh.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_collision_mesh.py new file mode 100644 index 00000000..67681cf7 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_collision_mesh.py @@ -0,0 +1,438 @@ +"""Decode retail RenderWare ClusteredMesh collision from Skate 3 RX2 assets.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from pathlib import Path +import struct +from typing import Iterable + + +RX2_TOC_RECORD_SIZE = 24 +RX2_TYPE_CLUSTERED_MESH = 0x00080006 +MESH_HEADER_SIZE = 96 +CLUSTER_HEADER_SIZE = 16 + + +@dataclass(frozen=True) +class CollisionTriangle: + a: tuple[float, float, float] + b: tuple[float, float, float] + c: tuple[float, float, float] + surface: int + edge_codes: tuple[int, int, int] | None = None + group_id: int | None = None + unit_flags: int = 0 + + +@dataclass(frozen=True) +class ClusteredMesh: + bounds_min: tuple[float, float, float] + bounds_max: tuple[float, float, float] + triangles: tuple[CollisionTriangle, ...] + triangle_cluster_indices: tuple[int, ...] + cluster_count: int + vertex_count: int + unit_count: int + compression_counts: tuple[tuple[int, int], ...] + mesh_flags: int + group_id_width: int + surface_id_width: int + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def _be_u16(data: bytes, offset: int) -> int: + return struct.unpack_from(">H", data, offset)[0] + + +def _be_u32(data: bytes, offset: int) -> int: + return struct.unpack_from(">I", data, offset)[0] + + +def _be_f32(data: bytes, offset: int) -> float: + return struct.unpack_from(">f", data, offset)[0] + + +def _be_vec3(data: bytes, offset: int) -> tuple[float, float, float]: + return struct.unpack_from(">3f", data, offset) + + +def _f32(value: float) -> float: + return struct.unpack(" tuple[int, int]: + _require(width in (1, 2), f"unsupported collision ID width {width}") + end = offset + width + _require(end <= len(data), "collision unit ID extends beyond its cluster") + # RenderWare's unit IDs are the exception inside the otherwise + # big-endian Xbox 360 blob. The native reader consumes the low byte first. + return int.from_bytes(data[offset:end], "little"), end + + +def _decode_vertices( + cluster: bytes, + vertex_count: int, + compression: int, + granularity: float, + vertex_data_end: int, +) -> list[tuple[float, float, float]]: + vertices: list[tuple[float, float, float]] = [] + if compression == 1: + _require(vertex_data_end >= 28, "compressed cluster omits its base") + base = struct.unpack_from(">3i", cluster, CLUSTER_HEADER_SIZE) + for index in range(vertex_count): + offset = CLUSTER_HEADER_SIZE + 12 + index * 6 + _require( + offset + 6 <= vertex_data_end, + "16-bit compressed vertex extends into the unit stream", + ) + delta = struct.unpack_from(">3h", cluster, offset) + vertices.append( + tuple( + _f32((base[axis] + delta[axis]) * granularity) + for axis in range(3) + ) + ) + elif compression == 2: + for index in range(vertex_count): + offset = CLUSTER_HEADER_SIZE + index * 12 + _require( + offset + 12 <= vertex_data_end, + "32-bit compressed vertex extends into the unit stream", + ) + value = struct.unpack_from(">3i", cluster, offset) + vertices.append( + tuple(_f32(component * granularity) for component in value) + ) + elif compression == 0: + for index in range(vertex_count): + offset = CLUSTER_HEADER_SIZE + index * 16 + _require( + offset + 16 <= vertex_data_end, + "uncompressed vertex extends into the unit stream", + ) + vertices.append(_be_vec3(cluster, offset)) + else: + raise ValueError( + f"unsupported RenderWare cluster compression {compression}" + ) + _require( + all(math.isfinite(component) for vertex in vertices for component in vertex), + "collision cluster contains a non-finite vertex", + ) + return vertices + + +def _triangulate_unit( + unit_type: int, + indices: list[int], +) -> list[tuple[int, int, int]]: + if unit_type == 1: + return [(indices[0], indices[1], indices[2])] + if unit_type == 2: + # This is the exact split used by rw::collision::ClusteredMesh: + # first 0-1-2, then 3-2-1. + return [ + (indices[0], indices[1], indices[2]), + (indices[3], indices[2], indices[1]), + ] + raise ValueError( + "RenderWare triangle-list units require a separately verified " + "triangulation path" + ) + + +def _decode_cluster( + cluster: bytes, + granularity: float, + group_id_width: int, + surface_id_width: int, +) -> tuple[list[CollisionTriangle], int, int, int]: + _require( + len(cluster) >= CLUSTER_HEADER_SIZE, + "collision cluster is smaller than its header", + ) + unit_count = _be_u16(cluster, 0) + unit_bytes = _be_u16(cluster, 2) + vertex_blocks = _be_u16(cluster, 4) + cluster_bytes = _be_u16(cluster, 8) + vertex_count = cluster[10] + compression = cluster[12] + _require( + cluster_bytes == len(cluster), + "collision cluster size does not match its table span", + ) + unit_offset = (vertex_blocks + 1) * 16 + unit_end = unit_offset + unit_bytes + _require( + CLUSTER_HEADER_SIZE <= unit_offset <= unit_end <= len(cluster), + "collision cluster has invalid vertex/unit spans", + ) + vertices = _decode_vertices( + cluster, + vertex_count, + compression, + granularity, + unit_offset, + ) + + triangles: list[CollisionTriangle] = [] + cursor = unit_offset + for _unit_index in range(unit_count): + _require(cursor < unit_end, "collision unit stream ends prematurely") + flags = cluster[cursor] + cursor += 1 + unit_type = flags & 0x0F + if unit_type == 1: + output_triangle_count = 1 + elif unit_type == 2: + output_triangle_count = 2 + elif unit_type == 3: + _require( + cursor < unit_end, + "collision triangle-list unit omits its triangle count", + ) + output_triangle_count = cluster[cursor] + cursor += 1 + else: + raise ValueError(f"unsupported collision unit type {unit_type}") + + index_count = output_triangle_count + 2 + indices_end = cursor + index_count + _require( + indices_end <= unit_end, + "collision unit vertex indices extend beyond the unit stream", + ) + indices = list(cluster[cursor:indices_end]) + cursor = indices_end + _require( + all(index < vertex_count for index in indices), + "collision unit references a missing cluster vertex", + ) + edge_codes: tuple[int, ...] | None = None + if flags & 0x20: + edge_codes = tuple(cluster[cursor : cursor + index_count]) + cursor += index_count + _require( + cursor <= unit_end, + "collision unit edge codes extend beyond the unit stream", + ) + group_id: int | None = None + if flags & 0x40: + group_id, cursor = _read_variable_id( + cluster, + cursor, + group_id_width, + ) + surface = 0 + if flags & 0x80: + surface, cursor = _read_variable_id( + cluster, + cursor, + surface_id_width, + ) + for triangle_index, (a, b, c) in enumerate( + _triangulate_unit(unit_type, indices) + ): + triangle_edge_codes: tuple[int, int, int] | None = None + if edge_codes is not None: + if unit_type == 1: + triangle_edge_codes = ( + edge_codes[0], + edge_codes[1], + edge_codes[2], + ) + else: + # Quad units split as 0-1-2 and 3-2-1. The generated + # diagonal is smooth; boundary codes retain their native + # corner association. University uses triangle units, but + # decoding this correctly keeps the general RX2 reader + # lossless enough for other districts. + triangle_edge_codes = ( + ( + edge_codes[0], + edge_codes[1], + 0x1A, + ) + if triangle_index == 0 + else ( + edge_codes[3], + edge_codes[2], + 0x1A, + ) + ) + triangles.append( + CollisionTriangle( + vertices[a], + vertices[b], + vertices[c], + surface, + triangle_edge_codes, + group_id, + flags, + ) + ) + + _require(cursor == unit_end, "collision unit stream has trailing bytes") + return triangles, vertex_count, unit_count, compression + + +def decode_clustered_mesh(data: bytes) -> ClusteredMesh: + """Decode one big-endian serialized rw::collision::ClusteredMesh.""" + + _require(len(data) >= MESH_HEADER_SIZE, "collision mesh is too small") + bounds_min = _be_vec3(data, 0) + bounds_max = _be_vec3(data, 16) + expected_triangles = _be_u32(data, 40) + kd_tree = _be_u32(data, 48) + cluster_table = _be_u32(data, 52) + granularity = _be_f32(data, 56) + mesh_flags = _be_u16(data, 60) + group_id_width = data[62] + surface_id_width = data[63] + cluster_count = _be_u32(data, 64) + mesh_bytes = _be_u32(data, 80) + _require( + all( + math.isfinite(component) + for point in (bounds_min, bounds_max) + for component in point + ), + "collision mesh has non-finite bounds", + ) + _require( + all(low <= high for low, high in zip(bounds_min, bounds_max)), + "collision mesh bounds are inverted", + ) + _require( + math.isfinite(granularity) and granularity > 0.0, + "collision mesh granularity is invalid", + ) + _require( + mesh_bytes <= len(data) and mesh_bytes >= MESH_HEADER_SIZE, + "collision mesh byte count is invalid", + ) + _require( + kd_tree >= MESH_HEADER_SIZE + and kd_tree + 48 <= cluster_table, + "collision KD-tree header is invalid", + ) + _require( + cluster_table >= MESH_HEADER_SIZE + and cluster_table + cluster_count * 4 <= mesh_bytes, + "collision cluster table is invalid", + ) + + cluster_offsets = [ + _be_u32(data, cluster_table + index * 4) + for index in range(cluster_count) + ] + _require( + cluster_offsets == sorted(cluster_offsets) + and len(set(cluster_offsets)) == len(cluster_offsets), + "collision cluster offsets are not strictly increasing", + ) + triangles: list[CollisionTriangle] = [] + triangle_cluster_indices: list[int] = [] + vertex_count = 0 + unit_count = 0 + compression_counts: dict[int, int] = {} + for cluster_index, cluster_offset in enumerate(cluster_offsets): + cluster_end = ( + cluster_offsets[cluster_index + 1] + if cluster_index + 1 < len(cluster_offsets) + else mesh_bytes + ) + _require( + cluster_offset >= cluster_table + cluster_count * 4 + and cluster_offset + CLUSTER_HEADER_SIZE <= cluster_end, + "collision cluster table points outside the mesh", + ) + declared_size = _be_u16(data, cluster_offset + 8) + _require( + cluster_offset + declared_size <= cluster_end, + "collision cluster overlaps the following cluster", + ) + try: + decoded, vertices, units, compression = _decode_cluster( + data[cluster_offset : cluster_offset + declared_size], + granularity, + group_id_width, + surface_id_width, + ) + except ValueError as error: + raise ValueError( + f"collision cluster {cluster_index} at " + f"0x{cluster_offset:X}: {error}" + ) from error + triangles.extend(decoded) + triangle_cluster_indices.extend([cluster_index] * len(decoded)) + vertex_count += vertices + unit_count += units + compression_counts[compression] = ( + compression_counts.get(compression, 0) + 1 + ) + + _require( + len(triangles) == expected_triangles, + "collision mesh triangle count does not match decoded clusters", + ) + return ClusteredMesh( + bounds_min=bounds_min, + bounds_max=bounds_max, + triangles=tuple(triangles), + triangle_cluster_indices=tuple(triangle_cluster_indices), + cluster_count=cluster_count, + vertex_count=vertex_count, + unit_count=unit_count, + compression_counts=tuple(sorted(compression_counts.items())), + mesh_flags=mesh_flags, + group_id_width=group_id_width, + surface_id_width=surface_id_width, + ) + + +def decode_rx2_clustered_meshes(data: bytes) -> list[ClusteredMesh]: + """Decode every ClusteredMesh section in one Xbox 360 RW4 RX2.""" + + _require( + len(data) >= 0x34 and data[:7] == b"\x89RW4xb2", + "asset is not an Xbox 360 RW4 RX2 resource", + ) + file_count = _be_u32(data, 0x20) + file_table = _be_u32(data, 0x30) + _require( + file_table + file_count * RX2_TOC_RECORD_SIZE <= len(data), + "RX2 section table extends beyond the file", + ) + result: list[ClusteredMesh] = [] + for record_index in range(file_count): + record_offset = file_table + record_index * RX2_TOC_RECORD_SIZE + section_offset = _be_u32(data, record_offset) + section_size = _be_u32(data, record_offset + 8) + section_type = _be_u32(data, record_offset + 20) + if section_type != RX2_TYPE_CLUSTERED_MESH: + continue + _require( + section_offset + section_size <= len(data), + "RX2 collision section extends beyond the file", + ) + result.append( + decode_clustered_mesh( + data[section_offset : section_offset + section_size] + ) + ) + return result + + +def decode_rx2_files(paths: Iterable[Path]) -> list[ClusteredMesh]: + result: list[ClusteredMesh] = [] + for path in paths: + result.extend(decode_rx2_clustered_meshes(path.read_bytes())) + return result diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_grind_splines.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_grind_splines.py new file mode 100644 index 00000000..ad8d8180 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_grind_splines.py @@ -0,0 +1,292 @@ +"""Decode retail Pegasus tSplineData grind paths from Skate 3 RX2 assets.""" + +from __future__ import annotations + +import math +from pathlib import Path +import re +import struct + + +RX2_TOC_RECORD_SIZE = 24 +RX2_TYPE_SPLINE_DATA = 0x00EB0004 +SPLINE_HEADER_SIZE = 16 +SPLINE_RAIL_SIZE = 32 +SPLINE_SEGMENT_SIZE = 144 +SPLINE_SEGMENT_PAYLOAD_SIZE = 120 +SPLINE_CONTINUITY_EPSILON = 1.0e-3 +CELL_SIM_STREAM_PATTERN = re.compile( + r"^cSim_(-?\d+)_(-?\d+)_high\.xsf$", + re.IGNORECASE, +) +CELL_LOCAL_CLASSIFICATION_MARGIN = 40.0 + + +def _vec3(data: bytes, offset: int) -> tuple[float, float, float]: + return struct.unpack_from(">3f", data, offset) + + +def _add( + *values: tuple[float, float, float], +) -> tuple[float, float, float]: + return tuple(sum(value[axis] for value in values) for axis in range(3)) + + +def _distance( + left: tuple[float, float, float], + right: tuple[float, float, float], +) -> float: + return math.sqrt( + sum((left[axis] - right[axis]) ** 2 for axis in range(3)) + ) + + +def grind_cell_translation( + stream_file: str, +) -> tuple[float, float, float] | None: + """Return a simulation cell's runtime-space translation, if named.""" + + match = CELL_SIM_STREAM_PATTERN.match(Path(stream_file).name) + if match is None: + return None + return float(match.group(1)), 0.0, float(match.group(2)) + + +def classify_grind_coordinate_frame( + stream_file: str, + native_segment_payloads: list[str] | tuple[str, ...], + *, + margin: float = CELL_LOCAL_CLASSIFICATION_MARGIN, +) -> str: + """Classify one rail as world-space, cell-local, or ambiguous. + + Skate 2's BAM streams mix both coordinate conventions, including within + one tSplineData asset. Classification therefore has to happen per rail. + The rail midpoint is compared with the origin and the stream cell center; + near-boundary cases remain untouched instead of being guessed. + """ + + translation = grind_cell_translation(stream_file) + if translation is None or not native_segment_payloads: + return "world_space" + if not math.isfinite(margin) or margin < 0.0: + raise ValueError("grind coordinate classification margin is invalid") + + midpoint_x = 0.0 + midpoint_z = 0.0 + endpoint_count = 0 + for payload_hex in native_segment_payloads: + payload = bytes.fromhex(payload_hex) + if len(payload) != SPLINE_SEGMENT_PAYLOAD_SIZE: + raise ValueError( + "retail grind segment payload must contain 120 bytes" + ) + values = struct.unpack(">30f", payload) + coefficient_a = values[0:3] + coefficient_b = values[4:7] + coefficient_c = values[8:11] + coefficient_d = values[12:15] + segment_end = _add( + coefficient_d, + coefficient_c, + coefficient_b, + coefficient_a, + ) + midpoint_x += coefficient_d[0] + segment_end[0] + midpoint_z += coefficient_d[2] + segment_end[2] + endpoint_count += 2 + + midpoint_x /= endpoint_count + midpoint_z /= endpoint_count + local_distance = math.hypot(midpoint_x, midpoint_z) + world_distance = math.hypot( + midpoint_x - translation[0], + midpoint_z - translation[2], + ) + if local_distance + margin < world_distance: + return "cell_local" + if world_distance + margin < local_distance: + return "world_space" + return "ambiguous" + + +def translate_native_segment_payload( + payload_hex: str, + translation: tuple[float, float, float], +) -> str: + """Translate one exact native cubic and its retail bounds metadata.""" + + payload = bytearray.fromhex(payload_hex) + if len(payload) != SPLINE_SEGMENT_PAYLOAD_SIZE: + raise ValueError("retail grind segment payload must contain 120 bytes") + if not all(math.isfinite(component) for component in translation): + raise ValueError("grind segment translation is not finite") + # Word 12 is polynomial D (the segment origin); words 20 and 24 are the + # native bounds vectors. A/B/C are derivatives and must remain unchanged. + for vector_word in (12, 20, 24): + for axis, component in enumerate(translation): + offset = (vector_word + axis) * 4 + value = struct.unpack_from(">f", payload, offset)[0] + struct.pack_into(">f", payload, offset, value + component) + return payload.hex() + + +def decode_grind_splines(data: bytes) -> list[dict[str, object]]: + """Return every native grind spline in one simulation RX2 resource. + + Retail spline segments are cubic polynomials: + + ``position(t) = D + C*t + B*t^2 + A*t^3`` + + The first 120 bytes of each 144-byte segment contain the four coefficient + vectors plus retail auxiliary data. The final 24 bytes are relocated + runtime links and are deliberately not copied into the portable manifest. + """ + + if len(data) < 0x34 or data[:7] != b"\x89RW4xb2": + raise ValueError("asset is not an Xbox 360 RW4 RX2 resource") + file_count = struct.unpack_from(">I", data, 0x20)[0] + file_table = struct.unpack_from(">I", data, 0x30)[0] + if file_table + file_count * RX2_TOC_RECORD_SIZE > len(data): + raise ValueError("RX2 section table extends beyond the file") + + result: list[dict[str, object]] = [] + for record_index in range(file_count): + record_offset = file_table + record_index * RX2_TOC_RECORD_SIZE + section_offset, _, section_size, _, _ = struct.unpack_from( + ">5I", + data, + record_offset, + ) + section_type = struct.unpack_from(">I", data, record_offset + 20)[0] + if section_type != RX2_TYPE_SPLINE_DATA: + continue + section_end = section_offset + section_size + if ( + section_offset + SPLINE_HEADER_SIZE > len(data) + or section_end > len(data) + ): + raise ValueError("RX2 spline section extends beyond the file") + + rail_count, segment_count, rail_table, segment_table = ( + struct.unpack_from(">4I", data, section_offset) + ) + expected_segment_table = ( + SPLINE_HEADER_SIZE + rail_count * SPLINE_RAIL_SIZE + ) + expected_size = ( + expected_segment_table + segment_count * SPLINE_SEGMENT_SIZE + ) + if ( + rail_table != SPLINE_HEADER_SIZE + or segment_table != expected_segment_table + or section_size != expected_size + ): + raise ValueError( + "retail tSplineData has an inconsistent table layout" + ) + + covered_segments: set[int] = set() + for rail_index in range(rail_count): + rail_offset = section_offset + rail_table + ( + rail_index * SPLINE_RAIL_SIZE + ) + ( + spline_id, + type_signature, + flags, + first_segment, + last_segment, + trailing_word, + ) = struct.unpack_from(">QQ4I", data, rail_offset) + if ( + first_segment < segment_table + or last_segment < first_segment + or (first_segment - segment_table) % SPLINE_SEGMENT_SIZE + or (last_segment - first_segment) % SPLINE_SEGMENT_SIZE + or last_segment + SPLINE_SEGMENT_SIZE > section_size + ): + raise ValueError( + f"retail grind rail {rail_index} has invalid segment links" + ) + + payloads: list[str] = [] + first_start: tuple[float, float, float] | None = None + previous_end: tuple[float, float, float] | None = None + for relative_segment in range( + first_segment, + last_segment + 1, + SPLINE_SEGMENT_SIZE, + ): + segment_index = ( + relative_segment - segment_table + ) // SPLINE_SEGMENT_SIZE + if segment_index in covered_segments: + raise ValueError( + "retail tSplineData rails share a segment record" + ) + covered_segments.add(segment_index) + segment_offset = section_offset + relative_segment + native_values = struct.unpack_from( + ">30f", + data, + segment_offset, + ) + if not all(math.isfinite(value) for value in native_values): + raise ValueError( + f"retail grind rail {rail_index} contains " + "non-finite native data" + ) + coefficient_a = _vec3(data, segment_offset) + coefficient_b = _vec3(data, segment_offset + 16) + coefficient_c = _vec3(data, segment_offset + 32) + coefficient_d = _vec3(data, segment_offset + 48) + segment_end = _add( + coefficient_d, + coefficient_c, + coefficient_b, + coefficient_a, + ) + if first_start is None: + first_start = coefficient_d + if ( + previous_end is not None + and _distance(previous_end, coefficient_d) + > SPLINE_CONTINUITY_EPSILON + ): + raise ValueError( + f"retail grind rail {rail_index} is discontinuous" + ) + previous_end = segment_end + payloads.append( + data[ + segment_offset : ( + segment_offset + SPLINE_SEGMENT_PAYLOAD_SIZE + ) + ].hex() + ) + + assert first_start is not None and previous_end is not None + result.append( + { + "section_index": record_index, + "section_offset": section_offset, + "rail_index": rail_index, + "spline_id": f"0x{spline_id:016X}", + "type_signature": f"0x{type_signature:016X}", + "flags": flags, + "trailing_word": trailing_word, + "closed": ( + _distance(first_start, previous_end) + <= SPLINE_CONTINUITY_EPSILON + ), + "segment_count": len(payloads), + "native_segment_payloads": payloads, + } + ) + + if len(covered_segments) != segment_count: + raise ValueError( + "retail tSplineData contains unreferenced segment records" + ) + return result diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_lightmap_uv.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_lightmap_uv.py new file mode 100644 index 00000000..11dc0428 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_lightmap_uv.py @@ -0,0 +1,343 @@ +"""Decode the secondary Xenos texture coordinate used by retail lightmaps.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Protocol + +import numpy + + +class VertexAttribute(Protocol): + offset: int + descriptor: bytes + + +@dataclass(frozen=True) +class LightmapUvDecode: + values: numpy.ndarray + format_code: int + offset: int + usage_index: int + + +@dataclass(frozen=True) +class DecalUvDecode: + values: numpy.ndarray + format_code: int + offset: int + usage_index: int + + +@dataclass(frozen=True) +class RetailWorldFrameDecode: + normals: numpy.ndarray + tangents: numpy.ndarray + tangent_handedness: numpy.ndarray + lightmap_format_code: int + lightmap_offset: int + tangent_format_code: int + tangent_offset: int + + +def _texture_coordinates( + attributes: Iterable[VertexAttribute], +) -> list[tuple[int, int, VertexAttribute]]: + texcoords: list[tuple[int, int, VertexAttribute]] = [] + for declaration_index, attribute in enumerate(attributes): + descriptor = bytes(attribute.descriptor) + if len(descriptor) != 16: + raise ValueError("Xenos vertex descriptors must contain 16 bytes") + if descriptor[9] == 5: + texcoords.append( + (int(descriptor[10]), declaration_index, attribute) + ) + texcoords.sort(key=lambda item: (item[0], item[1])) + return texcoords + + +def _secondary_texcoord( + attributes: Iterable[VertexAttribute], +) -> tuple[VertexAttribute, int] | None: + texcoords = _texture_coordinates(attributes) + if len(texcoords) < 2: + return None + usage_index, _declaration_index, attribute = texcoords[1] + return attribute, usage_index + + +def decode_decal_uvs( + data: bytes, + *, + vertex_buffer_offset: int, + vertex_count: int, + vertex_stride: int, + attributes: Iterable[VertexAttribute], +) -> DecalUvDecode | None: + """Decode zw from a half4/float4 primary TEXCOORD. + + decalenvironment shaders use this third UV pair for the art overlay while + xy remains the base diffuse UV. Two-component declarations have no + independent decal coordinates and return ``None``. + """ + + texcoords = _texture_coordinates(attributes) + if not texcoords: + return None + usage_index, _declaration_index, attribute = texcoords[0] + descriptor = bytes(attribute.descriptor) + format_code = int.from_bytes(descriptor[4:8], "big") + xenos_format = format_code & 0x3F + if xenos_format == 32: + component_bytes = 2 + dtype = numpy.dtype(">f2") + elif xenos_format == 38: + component_bytes = 4 + dtype = numpy.dtype(">f4") + else: + return None + offset = int(attribute.offset) + component_bytes * 2 + required_end = ( + vertex_buffer_offset + + max(0, vertex_count - 1) * vertex_stride + + offset + + component_bytes * 2 + ) + if required_end > len(data): + raise ValueError( + "retail decal UV data extends past the RX2 vertex buffer" + ) + if vertex_count == 0: + values = numpy.empty((0, 2), dtype=numpy.float32) + else: + values = numpy.ndarray( + shape=(vertex_count, 2), + dtype=dtype, + buffer=data, + offset=vertex_buffer_offset + offset, + strides=(vertex_stride, component_bytes), + ).astype(numpy.float32, copy=True) + if not numpy.isfinite(values).all(): + raise ValueError("retail decal UVs contain non-finite values") + return DecalUvDecode( + values=numpy.ascontiguousarray(values, dtype=numpy.float32), + format_code=format_code, + offset=offset, + usage_index=usage_index, + ) + + +def decode_lightmap_uvs( + data: bytes, + *, + vertex_buffer_offset: int, + vertex_count: int, + vertex_stride: int, + attributes: Iterable[VertexAttribute], +) -> LightmapUvDecode | None: + """Decode the second TEXCOORD without changing its retail sign bits. + + Static world shaders use ``abs(uv)`` because the signs also carry tangent + frame handedness. Keeping the signed values here lets the Blender importer + apply the correct shader-family policy rather than destroying provenance + during extraction. + """ + + selected = _secondary_texcoord(attributes) + if selected is None: + return None + attribute, usage_index = selected + descriptor = bytes(attribute.descriptor) + format_code = int.from_bytes(descriptor[4:8], "big") + xenos_format = format_code & 0x3F + offset = int(attribute.offset) + if vertex_count < 0 or vertex_stride <= 0 or offset < 0: + raise ValueError("invalid retail vertex-buffer dimensions") + + if xenos_format in (25, 26): + component_bytes = 2 + dtype = numpy.dtype(">i2") + elif xenos_format in (31, 32): + component_bytes = 2 + dtype = numpy.dtype(">f2") + elif xenos_format in (37, 38): + component_bytes = 4 + dtype = numpy.dtype(">f4") + else: + raise ValueError( + f"unsupported lightmap UV Xenos format 0x{format_code:08X}" + ) + + required_end = ( + vertex_buffer_offset + + max(0, vertex_count - 1) * vertex_stride + + offset + + component_bytes * 2 + ) + if required_end > len(data): + raise ValueError( + "retail lightmap UV data extends past the RX2 vertex buffer" + ) + if vertex_count == 0: + values = numpy.empty((0, 2), dtype=numpy.float32) + else: + values = numpy.ndarray( + shape=(vertex_count, 2), + dtype=dtype, + buffer=data, + offset=vertex_buffer_offset + offset, + strides=(vertex_stride, component_bytes), + ).astype(numpy.float32, copy=True) + if xenos_format in (25, 26): + values /= numpy.float32(32767.0) + if not numpy.isfinite(values).all(): + raise ValueError("retail lightmap UVs contain non-finite values") + return LightmapUvDecode( + values=numpy.ascontiguousarray(values, dtype=numpy.float32), + format_code=format_code, + offset=offset, + usage_index=usage_index, + ) + + +def decode_retail_world_frame( + data: bytes, + *, + vertex_buffer_offset: int, + vertex_count: int, + vertex_stride: int, + attributes: Iterable[VertexAttribute], +) -> RetailWorldFrameDecode | None: + """Decode the exact static-world normal, tangent, and handedness. + + Skate 3's environment vertex layouts do not store a conventional normal + attribute. Their signed SHORT4 secondary TEXCOORD stores the lightmap + unwrap in ``xy`` and the normal's ``xy`` in ``zw``. The sign of unwrap + ``y`` reconstructs normal ``z``; unwrap ``x`` is the tangent handedness. + The usage-6 PACKED11_11_10N attribute is the authored tangent. + """ + + attributes = tuple(attributes) + selected = _secondary_texcoord(attributes) + if selected is None: + return None + lightmap_attribute, _usage_index = selected + lightmap_descriptor = bytes(lightmap_attribute.descriptor) + lightmap_format_code = int.from_bytes( + lightmap_descriptor[4:8], "big" + ) + if (lightmap_format_code & 0x3F) != 26: + return None + + tangent_attribute = None + for attribute in attributes: + descriptor = bytes(attribute.descriptor) + if len(descriptor) != 16: + raise ValueError("Xenos vertex descriptors must contain 16 bytes") + format_code = int.from_bytes(descriptor[4:8], "big") + if descriptor[9] == 6 and (format_code & 0x3F) == 16: + tangent_attribute = attribute + break + if tangent_attribute is None: + return None + + lightmap_offset = int(lightmap_attribute.offset) + tangent_offset = int(tangent_attribute.offset) + if ( + vertex_count < 0 + or vertex_stride <= 0 + or lightmap_offset < 0 + or tangent_offset < 0 + ): + raise ValueError("invalid retail vertex-buffer dimensions") + required_end = ( + vertex_buffer_offset + + max(0, vertex_count - 1) * vertex_stride + + max(lightmap_offset + 8, tangent_offset + 4) + ) + if required_end > len(data): + raise ValueError( + "retail world tangent-frame data extends past the RX2 " + "vertex buffer" + ) + + if vertex_count == 0: + short4 = numpy.empty((0, 4), dtype=numpy.int16) + words = numpy.empty((0,), dtype=numpy.uint32) + else: + short4 = numpy.ndarray( + shape=(vertex_count, 4), + dtype=numpy.dtype(">i2"), + buffer=data, + offset=vertex_buffer_offset + lightmap_offset, + strides=(vertex_stride, 2), + ).astype(numpy.int32, copy=True) + words = numpy.ndarray( + shape=(vertex_count,), + dtype=numpy.dtype(">u4"), + buffer=data, + offset=vertex_buffer_offset + tangent_offset, + strides=(vertex_stride,), + ).astype(numpy.uint32, copy=True) + + normals = numpy.empty((vertex_count, 3), dtype=numpy.float32) + normals[:, :2] = short4[:, 2:4].astype(numpy.float32) + normals[:, :2] /= numpy.float32(32767.0) + normal_z_squared = ( + 1.0 + - normals[:, 0] * normals[:, 0] + - normals[:, 1] * normals[:, 1] + ) + normals[:, 2] = numpy.sqrt( + numpy.maximum(normal_z_squared, numpy.float32(0.0)) + ) + normals[:, 2] *= numpy.where( + short4[:, 1] > 0, + numpy.float32(1.0), + numpy.float32(-1.0), + ) + + def signed_component( + values: numpy.ndarray, shift: int, bits: int + ) -> numpy.ndarray: + mask = numpy.uint32((1 << bits) - 1) + decoded = ((values >> numpy.uint32(shift)) & mask).astype( + numpy.int32 + ) + sign_bit = 1 << (bits - 1) + decoded[decoded >= sign_bit] -= 1 << bits + return decoded + + tangents = numpy.empty((vertex_count, 3), dtype=numpy.float32) + tangents[:, 0] = signed_component(words, 0, 11).astype(numpy.float32) + tangents[:, 1] = signed_component(words, 11, 11).astype(numpy.float32) + tangents[:, 2] = signed_component(words, 22, 10).astype(numpy.float32) + tangents[:, 0:2] /= numpy.float32(1023.0) + tangents[:, 2] /= numpy.float32(511.0) + + tangent_handedness = numpy.where( + short4[:, 0] > 0, + numpy.float32(1.0), + numpy.float32(-1.0), + ) + if not ( + numpy.isfinite(normals).all() + and numpy.isfinite(tangents).all() + and numpy.isfinite(tangent_handedness).all() + ): + raise ValueError("retail world tangent frame contains non-finite data") + + tangent_descriptor = bytes(tangent_attribute.descriptor) + return RetailWorldFrameDecode( + normals=numpy.ascontiguousarray(normals, dtype=numpy.float32), + tangents=numpy.ascontiguousarray(tangents, dtype=numpy.float32), + tangent_handedness=numpy.ascontiguousarray( + tangent_handedness, dtype=numpy.float32 + ), + lightmap_format_code=lightmap_format_code, + lightmap_offset=lightmap_offset, + tangent_format_code=int.from_bytes( + tangent_descriptor[4:8], "big" + ), + tangent_offset=tangent_offset, + ) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_texture_decode.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_texture_decode.py new file mode 100644 index 00000000..24e2b72c --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/retail_texture_decode.py @@ -0,0 +1,108 @@ +"""Decode Xbox 360 retail texture formats that need RX2-specific handling.""" + +from __future__ import annotations + + +B5G6R5_FORMAT_ID = 0x44 +B5G6R5_DECODER_NAME = "xenos-tiled-packed-b5g6r5-v1" + + +def _align(value: int, alignment: int) -> int: + return (value + alignment - 1) & ~(alignment - 1) + + +def _log2_ceil(value: int) -> int: + if value <= 0: + raise ValueError("texture dimensions must be positive") + return (value - 1).bit_length() + + +def _packed_base_offset(width: int, height: int) -> tuple[int, int]: + """Return the level-zero offset in a Xenos packed mip tail. + + Xenos packs a texture into a shared 32x32 tile once either dimension is + 16 texels or smaller. Level zero is placed at x=16 for square/tall + textures and y=16 for wide textures. This is the level-zero specialization + of Xenia's GetPackedMipOffset. + """ + + log2_width = _log2_ceil(width) + log2_height = _log2_ceil(height) + if min(log2_width, log2_height) > 4: + return 0, 0 + if log2_width > log2_height: + return 0, 16 + return 16, 0 + + +def _tiled_offset_2d( + x: int, + y: int, + pitch: int, + bytes_per_texel_log2: int, +) -> int: + """Return the Xenos tiled byte address for a 2D texel.""" + + pitch = _align(pitch, 32) + macro = ( + (x >> 5) + (y >> 5) * (pitch >> 5) + ) << (bytes_per_texel_log2 + 7) + micro = ((x & 7) + ((y & 0xE) << 2)) << bytes_per_texel_log2 + offset = ( + macro + + ((micro & ~0xF) << 1) + + (micro & 0xF) + + ((y & 1) << 4) + ) + return ( + ((offset & ~0x1FF) << 3) + + ((y & 16) << 7) + + ((offset & 0x1C0) << 2) + + (((((y & 8) >> 2) + (x >> 3)) & 3) << 6) + + (offset & 0x3F) + ) + + +def decode_b5g6r5( + raw: bytes, + width: int, + height: int, +) -> bytes: + """Decode a tiled Xenos B5G6R5 base level to row-major RGBA8888. + + RX2 stores each 16-bit word in big-endian byte order. The external UTT + parser previously swapped those bytes and then interpreted them as + big-endian again. It also read packed base levels from tile origin, + exposing zero padding as black stripes. + """ + + if width <= 0 or height <= 0: + raise ValueError("texture dimensions must be positive") + packed_x, packed_y = _packed_base_offset(width, height) + rgba = bytearray(width * height * 4) + for y in range(height): + for x in range(width): + source_offset = _tiled_offset_2d( + x + packed_x, + y + packed_y, + width, + 1, + ) + if source_offset + 2 > len(raw): + raise ValueError( + "B5G6R5 payload is too short for " + f"{width}x{height} texel ({x}, {y}): " + f"need {source_offset + 2} bytes, have {len(raw)}" + ) + value = int.from_bytes( + raw[source_offset : source_offset + 2], + "big", + ) + output_offset = (y * width + x) * 4 + rgba[output_offset] = ((value >> 11) & 0x1F) * 255 // 31 + rgba[output_offset + 1] = ( + ((value >> 5) & 0x3F) * 255 // 63 + ) + rgba[output_offset + 2] = (value & 0x1F) * 255 // 31 + rgba[output_offset + 3] = 255 + return bytes(rgba) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py new file mode 100644 index 00000000..9931d517 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py @@ -0,0 +1,339 @@ +"""Read Skate 3 XST/SFIL stream assets without altering the source archives.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import struct + + +ATOC_HEADER_SIZE = 24 +ATOC_RECORD_SIZE = 64 +SFIL_HEADER_SIZE = 128 +SFIL_RECORD_ALIGNMENT = 128 +SFIL_ASSET_HEADER_SIZE = 128 +SFIL_SECTION_TABLE_OFFSET = 128 +SFIL_SECTION_DATA_OFFSET = 208 + +ASSET_TYPE_SIMULATION = 0x00000001 +ASSET_TYPE_MODEL = 0x00000004 +ASSET_TYPE_TEXTURE = 0x00001000 + + +class StreamFormatError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class AssetRecord: + asset_id: int + processor_id: int + total_size: int + header_size: int + alignment: int + gpu_size: int + asset_type: int + raw_record: bytes + + +@dataclass(frozen=True, slots=True) +class StreamAsset: + record: AssetRecord + source_path: Path + source_offset: int + stored_size: int + data: bytes + + +def align_up(value: int, alignment: int) -> int: + return (value + alignment - 1) & ~(alignment - 1) + + +def read_atoc(path: str | Path) -> list[AssetRecord]: + source = Path(path) + data = source.read_bytes() + if len(data) < ATOC_HEADER_SIZE or data[:4] != b"ATOC": + raise StreamFormatError(f"{source} is not an ATOC table") + version = struct.unpack_from(">I", data, 4)[0] + if version != 3: + raise StreamFormatError(f"{source} uses unsupported ATOC version {version}") + count = struct.unpack_from(">I", data, 16)[0] + expected = ATOC_HEADER_SIZE + count * ATOC_RECORD_SIZE + if expected != len(data): + raise StreamFormatError( + f"{source} has {len(data)} bytes; expected {expected} for {count} records" + ) + + records: list[AssetRecord] = [] + for index in range(count): + start = ATOC_HEADER_SIZE + index * ATOC_RECORD_SIZE + raw = data[start : start + ATOC_RECORD_SIZE] + records.append( + AssetRecord( + asset_id=struct.unpack_from(">Q", raw, 0)[0], + processor_id=struct.unpack_from(">I", raw, 8)[0], + total_size=struct.unpack_from(">I", raw, 12)[0], + header_size=struct.unpack_from(">I", raw, 16)[0], + alignment=struct.unpack_from(">I", raw, 20)[0], + gpu_size=struct.unpack_from(">I", raw, 32)[0], + asset_type=struct.unpack_from(">I", raw, 36)[0], + raw_record=raw, + ) + ) + return records + + +def decompress_refpack(source: bytes) -> bytes: + if len(source) < 6 or source[:2] != b"\x10\xFB": + raise StreamFormatError("RefPack payload has no 0x10FB header") + + expected_size = int.from_bytes(source[2:5], "big") + cursor = 5 + output = bytearray() + + def copy_literals(count: int) -> None: + nonlocal cursor + end = cursor + count + if end > len(source): + raise StreamFormatError("RefPack literal run exceeds its payload") + output.extend(source[cursor:end]) + cursor = end + + def copy_reference(offset: int, count: int) -> None: + if offset <= 0 or offset > len(output): + raise StreamFormatError( + f"RefPack back-reference {offset} exceeds {len(output)} output bytes" + ) + for _ in range(count): + output.append(output[-offset]) + + while len(output) < expected_size: + if cursor >= len(source): + raise StreamFormatError("RefPack payload ended before its output was complete") + control = source[cursor] + cursor += 1 + + if control < 0x80: + if cursor >= len(source): + raise StreamFormatError("truncated two-byte RefPack command") + next_byte = source[cursor] + cursor += 1 + copy_literals(control & 0x03) + copy_reference( + ((control & 0x60) << 3) + next_byte + 1, + ((control & 0x1C) >> 2) + 3, + ) + elif control < 0xC0: + if cursor + 2 > len(source): + raise StreamFormatError("truncated three-byte RefPack command") + first, second = source[cursor : cursor + 2] + cursor += 2 + copy_literals((first >> 6) & 0x03) + copy_reference( + ((first & 0x3F) << 8) + second + 1, + (control & 0x3F) + 4, + ) + elif control < 0xE0: + if cursor + 3 > len(source): + raise StreamFormatError("truncated four-byte RefPack command") + first, second, third = source[cursor : cursor + 3] + cursor += 3 + copy_literals(control & 0x03) + copy_reference( + ((control & 0x10) << 12) + (first << 8) + second + 1, + ((control & 0x0C) << 6) + third + 5, + ) + elif control < 0xFC: + copy_literals(((control & 0x1F) << 2) + 4) + else: + copy_literals(control & 0x03) + break + + if len(output) != expected_size: + raise StreamFormatError( + f"RefPack produced {len(output)} bytes; expected {expected_size}" + ) + return bytes(output) + + +def _decode_section( + source: bytes, + cursor: int, + uncompressed_size: int, + stored_size: int, + compression: int, +) -> tuple[bytes, int]: + if uncompressed_size == 0 and stored_size == 0: + return b"", cursor + end = cursor + stored_size + if end > len(source): + raise StreamFormatError("SFIL section exceeds its source file") + payload = source[cursor:end] + if compression == 0: + decoded = payload + elif compression == 1: + decoded = decompress_refpack(payload) + else: + raise StreamFormatError(f"unsupported SFIL compression method {compression}") + if len(decoded) != uncompressed_size: + raise StreamFormatError( + f"SFIL section decoded to {len(decoded)} bytes; " + f"expected {uncompressed_size}" + ) + return decoded, end + + +def read_sfil( + path: str | Path, + records: list[AssetRecord], + *, + require_all_records: bool = True, +) -> list[StreamAsset]: + source_path = Path(path) + source = source_path.read_bytes() + if len(source) < SFIL_HEADER_SIZE or source[:4] != b"SFIL": + raise StreamFormatError(f"{source_path} is not an SFIL stream") + + by_id = {record.asset_id: record for record in records} + first_asset_offset = struct.unpack_from(">I", source, 16)[0] + if first_asset_offset < SFIL_HEADER_SIZE or first_asset_offset > len(source): + raise StreamFormatError( + f"{source_path} has invalid first asset offset 0x{first_asset_offset:X}" + ) + + assets: list[StreamAsset] = [] + cursor = first_asset_offset + seen_ids: set[int] = set() + + while cursor < len(source): + if not any(source[cursor:]): + break + if cursor + SFIL_SECTION_DATA_OFFSET > len(source): + if any(source[cursor:]): + raise StreamFormatError(f"truncated SFIL asset at 0x{cursor:X}") + break + + asset_id = struct.unpack_from(">Q", source, cursor)[0] + stored_size = struct.unpack_from(">I", source, cursor + 8)[0] + asset_header_size = struct.unpack_from(">I", source, cursor + 12)[0] + asset_stride = struct.unpack_from(">I", source, cursor + 16)[0] + record = by_id.get(asset_id) + if record is None: + raise StreamFormatError( + f"SFIL asset 0x{asset_id:016X} is absent from its ATOC" + ) + if asset_id in seen_ids: + raise StreamFormatError(f"duplicate SFIL asset 0x{asset_id:016X}") + + if stored_size == record.total_size and asset_header_size >= SFIL_HEADER_SIZE: + data_start = cursor + asset_header_size + data_end = data_start + stored_size + if data_end > len(source): + raise StreamFormatError( + f"raw SFIL asset 0x{asset_id:016X} exceeds its source file" + ) + decoded = source[data_start:data_end] + else: + section_values = struct.unpack_from( + ">11I", source, cursor + SFIL_SECTION_TABLE_OFFSET + ) + cpu_size, cpu_stored, cpu_compression = section_values[:3] + gpu_size, gpu_stored, gpu_compression = section_values[8:11] + if stored_size != 0x50 + cpu_stored + gpu_stored: + raise StreamFormatError( + f"SFIL asset 0x{asset_id:016X} has inconsistent stored size" + ) + + data_cursor = cursor + SFIL_SECTION_DATA_OFFSET + cpu, data_cursor = _decode_section( + source, data_cursor, cpu_size, cpu_stored, cpu_compression + ) + gpu, data_cursor = _decode_section( + source, data_cursor, gpu_size, gpu_stored, gpu_compression + ) + decoded = cpu + gpu + if len(decoded) != record.total_size: + raise StreamFormatError( + f"asset 0x{asset_id:016X} decoded to {len(decoded)} bytes; " + f"ATOC declares {record.total_size}" + ) + + assets.append( + StreamAsset( + record=record, + source_path=source_path, + source_offset=cursor, + stored_size=stored_size, + data=decoded, + ) + ) + seen_ids.add(asset_id) + minimum_stride = asset_header_size + stored_size + if ( + asset_stride < minimum_stride + or asset_stride % first_asset_offset != 0 + or cursor + asset_stride > len(source) + ): + raise StreamFormatError( + f"SFIL asset 0x{asset_id:016X} in {source_path} " + f"has invalid stride 0x{asset_stride:X}" + ) + cursor += asset_stride + + missing = set(by_id) - seen_ids + if require_all_records and missing: + missing_text = ", ".join(f"0x{asset_id:016X}" for asset_id in sorted(missing)) + raise StreamFormatError(f"ATOC assets missing from SFIL: {missing_text}") + return assets + + +def load_global_stream( + stream_directory: str | Path, + stream_name: str, + district_name: str = "DIST_MegaPark", +) -> list[StreamAsset]: + root = Path(stream_directory) + records = read_atoc(root / f"{district_name}_{stream_name}.xst") + return read_sfil(root / f"c{stream_name}_Global.xsf", records) + + +def load_district_stream( + stream_directory: str | Path, + stream_name: str, + district_name: str, +) -> list[StreamAsset]: + """Load one ATOC whose assets are distributed over global and cell SFILs.""" + + root = Path(stream_directory) + records = read_atoc(root / f"{district_name}_{stream_name}.xst") + stream_files = sorted(root.glob(f"c{stream_name}_*.xsf")) + if not stream_files: + raise StreamFormatError( + f"{root} contains no c{stream_name}_*.xsf stream files" + ) + + assets_by_id: dict[int, StreamAsset] = {} + for stream_file in stream_files: + for asset in read_sfil( + stream_file, + records, + require_all_records=False, + ): + asset_id = asset.record.asset_id + previous = assets_by_id.get(asset_id) + if previous is not None: + if previous.record != asset.record or previous.data != asset.data: + raise StreamFormatError( + f"asset 0x{asset_id:016X} has conflicting SFIL copies" + ) + continue + assets_by_id[asset_id] = asset + + expected_ids = {record.asset_id for record in records} + missing = expected_ids - set(assets_by_id) + if missing: + missing_text = ", ".join(f"0x{asset_id:016X}" for asset_id in sorted(missing)) + raise StreamFormatError( + f"ATOC assets missing from district SFILs: {missing_text}" + ) + return [assets_by_id[record.asset_id] for record in records] diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_skate2_lightmaps.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_skate2_lightmaps.py new file mode 100644 index 00000000..b721ab4c --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_skate2_lightmaps.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Verify Skate 2 retail layer pages and UVs in an exported SKATE package.""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import struct +import sys + +from PIL import Image + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import Reader, VERTEX_BYTES_V12 # noqa: E402 + + +EXPECTED_LIGHTMAPPED_MATERIALS = 42_249 +EXPECTED_LIGHTMAP_IMAGES = 2_892 + + +def _png_rgba_blender_order(path: Path) -> bytes: + with Image.open(path) as image: + rgba = image.convert("RGBA") + width, height = rgba.size + top_to_bottom = rgba.tobytes() + row_bytes = width * 4 + return b"".join( + top_to_bottom[row * row_bytes : (row + 1) * row_bytes] + for row in range(height - 1, -1, -1) + ) + + +def _read_retail_material( + reader: Reader, index: int +) -> tuple[dict[str, tuple[int, int, int, int]], dict[str, list[str]]]: + retail_enabled = reader.u32(f"material {index} retail enabled") + if not retail_enabled: + return {}, {} + reader.skip(8 + 4 + 4, f"material {index} retail identity") + reader.string(f"material {index} retail shader") + reader.skip(4 + 4, f"material {index} retail family/flags") + binding_count = reader.u32(f"material {index} retail binding count") + bindings: dict[str, tuple[int, int, int, int]] = {} + for binding_index in range(binding_count): + semantic = reader.string( + f"material {index} binding {binding_index} semantic" + ) + bindings[semantic] = struct.unpack( + "<4I", + reader.take( + 4 * 4, + f"material {index} binding {binding_index} fields", + ), + ) + parameter_count = reader.u32( + f"material {index} retail parameter count" + ) + parameters: dict[str, list[str]] = {} + for parameter_index in range(parameter_count): + name = reader.string( + f"material {index} parameter {parameter_index} name" + ) + value_count = reader.u32( + f"material {index} parameter {parameter_index} values" + ) + parameters[name] = [ + reader.string( + f"material {index} parameter {parameter_index} " + f"value {value_index}" + ) + for value_index in range(value_count) + ] + reader.string(f"material {index} retail source") + return bindings, parameters + + +def verify(package_path: Path, aliases_path: Path) -> dict[str, object]: + alias_document = json.loads(aliases_path.read_text(encoding="utf-8")) + aliases = alias_document["aliases"] + aliases_by_texture = { + str(entry["texture_id"]): entry + for entry in aliases.values() + } + chromaticity_aliases_by_texture = { + str(entry["texture_id"]): entry + for entry in alias_document["chromaticity_aliases"].values() + } + cache_root = Path(alias_document["cache_root"]) + + reader = Reader(package_path.read_bytes()) + magic = reader.take(8, "magic") + if magic not in (b"SKATE12\0", b"SKATE13\0", b"SKATE14\0"): + raise RuntimeError(f"expected SKATE12/13 package, found {magic!r}") + if reader.u32("endian marker") != 0x12345678: + raise RuntimeError("invalid SKATE endian marker") + map_name = reader.string("map name") + reader.skip(49 * 4, "map metadata") + counts = struct.unpack("<9I", reader.take(9 * 4, "count table")) + material_count, texture_count, vertex_count, index_count = counts[:4] + + lightmap_material_ids: set[int] = set() + material_lightmaps: dict[int, int] = {} + material_chromaticity: dict[int, int] = {} + component_counts = [0, 0, 0] + lightmap_strengths: set[float] = set() + for index in range(material_count): + reader.string(f"material {index} name") + fields = reader.take(76, f"material {index} fields") + lightmap_texture = struct.unpack_from(" 2: + raise RuntimeError( + f"material {index} has invalid component {component}" + ) + component_counts[component] += 1 + material_chromaticity[material_id] = chromaticity_texture + + if len(lightmap_material_ids) != EXPECTED_LIGHTMAPPED_MATERIALS: + raise RuntimeError( + f"package has {len(lightmap_material_ids)} lightmapped " + f"materials, expected {EXPECTED_LIGHTMAPPED_MATERIALS}" + ) + if lightmap_strengths != {0.25}: + raise RuntimeError( + f"retail lightmap strengths changed: {lightmap_strengths}" + ) + + lightmap_texture_ids = set(material_lightmaps.values()) + chromaticity_texture_ids = set(material_chromaticity.values()) + texture_names: dict[int, str] = {} + texture_records: dict[int, tuple[int, int, int, bytes]] = {} + for index in range(texture_count): + texture_id = index + 1 + name = reader.string(f"texture {index} name") + width = reader.u32(f"texture {index} width") + height = reader.u32(f"texture {index} height") + color_space = reader.u32(f"texture {index} color space") + rgba8 = reader.stored(width * height * 4, f"texture {index}") + texture_names[texture_id] = name + if ( + texture_id in lightmap_texture_ids + or texture_id in chromaticity_texture_ids + ): + texture_records[texture_id] = ( + width, + height, + color_space, + rgba8, + ) + + package_lightmap_names = { + texture_names[texture_id] + for texture_id in material_lightmaps.values() + } + if len(package_lightmap_names) != EXPECTED_LIGHTMAP_IMAGES: + raise RuntimeError( + f"package has {len(package_lightmap_names)} lightmap images, " + f"expected {EXPECTED_LIGHTMAP_IMAGES}" + ) + unknown_names = package_lightmap_names - set(aliases_by_texture) + if unknown_names: + raise RuntimeError( + f"package contains unknown Skate 2 pages: " + f"{sorted(unknown_names)[:10]}" + ) + + package_chromaticity_names = { + texture_names[texture_id] + for texture_id in material_chromaticity.values() + } + if len(package_chromaticity_names) < 7_000: + raise RuntimeError( + "package contains too few Skate 2 chromaticity images: " + f"{len(package_chromaticity_names)}" + ) + unknown_chromaticity = ( + package_chromaticity_names + - set(chromaticity_aliases_by_texture) + ) + if unknown_chromaticity: + raise RuntimeError( + "package contains unknown Skate 2 chromaticity pages: " + f"{sorted(unknown_chromaticity)[:10]}" + ) + + exact_payload_bytes = 0 + for texture_id in sorted(lightmap_texture_ids): + name = texture_names[texture_id] + alias = aliases_by_texture[name] + width, height, color_space, rgba8 = texture_records[texture_id] + expected_size = (int(alias["width"]), int(alias["height"])) + if (width, height) != expected_size or color_space != 0: + raise RuntimeError( + f"{name!r} changed format: {(width, height, color_space)}" + ) + source = _png_rgba_blender_order(cache_root / str(alias["png"])) + if rgba8 != source: + raise RuntimeError( + f"{name!r} changed pixels during Blender export" + ) + exact_payload_bytes += len(rgba8) + + exact_chromaticity_payload_bytes = 0 + for texture_id in sorted(chromaticity_texture_ids): + name = texture_names[texture_id] + alias = chromaticity_aliases_by_texture[name] + width, height, color_space, rgba8 = texture_records[texture_id] + expected_size = (int(alias["width"]), int(alias["height"])) + if (width, height) != expected_size or color_space != 0: + raise RuntimeError( + f"{name!r} changed format: " + f"{(width, height, color_space)}" + ) + source = _png_rgba_blender_order(cache_root / str(alias["png"])) + if rgba8 != source: + raise RuntimeError( + f"{name!r} changed chromaticity pixels during export" + ) + exact_chromaticity_payload_bytes += len(rgba8) + + vertex_bytes = reader.stored( + vertex_count * VERTEX_BYTES_V12, + "visual vertex block", + ) + reader.stored(index_count * 4, "visual index block") + used_materials: set[int] = set() + lightmapped_vertices = 0 + distinct_uv_vertices = 0 + uv_min = [math.inf, math.inf] + uv_max = [-math.inf, -math.inf] + for offset in range(0, len(vertex_bytes), VERTEX_BYTES_V12): + material_id = struct.unpack_from(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument("aliases", type=Path) + args = parser.parse_args() + result = verify(args.package.resolve(), args.aliases.resolve()) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_b5g6r5.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_b5g6r5.py new file mode 100644 index 00000000..2b8f7b71 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_b5g6r5.py @@ -0,0 +1,67 @@ +"""Verify University B5G6R5 extraction and known retail constant maps.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from PIL import Image + +from retail_texture_decode import B5G6R5_DECODER_NAME + + +EXPECTED_CONSTANTS = { + "0x2c70170a000b0040": (255, 255, 255, 255), + "0x0000043d03e3870a": (131, 129, 255, 255), + "0x0000475d03e3870a": (131, 129, 255, 255), + "0x000002b403e3870a": (131, 129, 131, 255), +} + + +def verify(manifest_path: Path) -> None: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + root = manifest_path.parent + textures = manifest["textures"] + decoded_count = 0 + for texture_id, texture in textures.items(): + if texture["format"] != "B5G6R5": + continue + decoded_count += 1 + if texture.get("decoder") != B5G6R5_DECODER_NAME: + raise RuntimeError( + f"{texture_id} did not use {B5G6R5_DECODER_NAME}" + ) + + for texture_id, expected_pixel in EXPECTED_CONSTANTS.items(): + texture = textures[texture_id] + image_path = root / texture["png"] + with Image.open(image_path) as image: + pixel_bytes = image.convert("RGBA").tobytes() + pixels = { + tuple(pixel_bytes[offset : offset + 4]) + for offset in range(0, len(pixel_bytes), 4) + } + if pixels != {expected_pixel}: + raise RuntimeError( + f"{texture_id} is not the expected constant map: " + f"actual={sorted(pixels)[:8]} expected={expected_pixel}" + ) + + print( + "UNIVERSITY_B5G6R5_OK " + f"textures={decoded_count} constants={len(EXPECTED_CONSTANTS)} " + f"decoder={B5G6R5_DECODER_NAME}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + args = parser.parse_args() + verify(args.manifest.resolve()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_collision.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_collision.py new file mode 100644 index 00000000..21f1639b --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_collision.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Verify University retail collision and packed surfaces through SKATE.""" + +from __future__ import annotations + +from collections import defaultdict +import argparse +import hashlib +import json +from pathlib import Path +import struct +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import analyze_package # noqa: E402 +from retail_collision_mesh import decode_rx2_clustered_meshes # noqa: E402 + + +def _f32(value: float) -> float: + return struct.unpack(" float: + left = tuple(b[axis] - a[axis] for axis in range(3)) + right = tuple(c[axis] - a[axis] for axis in range(3)) + cross = ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + return sum(component * component for component in cross) + + +def _canonical_oriented_record( + points: tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ], +) -> bytes: + rotations = ( + points, + (points[1], points[2], points[0]), + (points[2], points[0], points[1]), + ) + canonical = min(rotations) + return struct.pack( + "<9q", + *( + round(component * 1_000_000.0) + for point in canonical + for component in point + ), + ) + + +def _canonical_oriented_edge_record( + points: tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ], + edge_codes: tuple[int, int, int], +) -> bytes: + rotations = tuple( + ( + points[offset:] + points[:offset], + edge_codes[offset:] + edge_codes[:offset], + ) + for offset in range(3) + ) + rotated_points, rotated_codes = min(rotations, key=lambda item: item[0]) + return _canonical_oriented_record(rotated_points) + bytes(rotated_codes) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + parser.add_argument("--expected", type=Path) + args = parser.parse_args() + + manifest_path = args.manifest.resolve() + cache_root = manifest_path.parent + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + by_surface: dict[int, list[object]] = defaultdict(list) + source_assets = 0 + source_meshes = 0 + source_clusters = 0 + source_triangles = 0 + for asset in manifest["simulation_assets"]: + if not asset.get("collision_meshes"): + continue + path = cache_root / Path(str(asset["rx2"]).replace("\\", "/")) + meshes = decode_rx2_clustered_meshes(path.read_bytes()) + source_assets += 1 + source_meshes += len(meshes) + for mesh in meshes: + source_clusters += mesh.cluster_count + source_triangles += len(mesh.triangles) + for triangle in mesh.triangles: + by_surface[triangle.surface].append(triangle) + + analysis = analyze_package(args.package.resolve(), include_payloads=True) + material_by_name = { + str(material["name"]): material + for material in analysis["_materials"] + } + material_by_id = { + int(material["id"]): material + for material in analysis["_materials"] + } + expected_xor: dict[int, int] = defaultdict(int) + expected_sum: dict[int, int] = defaultdict(int) + expected_counts: dict[int, int] = defaultdict(int) + expected_edge_xor = 0 + expected_edge_sum = 0 + seen_positions: set[tuple[tuple[float, float, float], ...]] = set() + seen_oriented: set[tuple[tuple[float, float, float], ...]] = set() + degenerate = 0 + same_wound_duplicates = 0 + retained_reverse_wound = 0 + surfaces = sorted(by_surface) + for surface in surfaces: + name = f"MAT_RETAIL_COLLISION_{surface:04X}" + material = material_by_name.get(name) + if material is None: + raise RuntimeError(f"SKATE package omits retail material {name}") + encoded = ( + int(material["audio_surface"]) + | (int(material["physics_surface"]) << 7) + | (int(material["surface_pattern"]) << 12) + ) + if encoded != surface: + raise RuntimeError( + f"{name} changed packed surface: " + f"0x{encoded:04X} != 0x{surface:04X}" + ) + for triangle in by_surface[surface]: + points = tuple( + tuple(_f32(component) for component in point) + for point in (triangle.a, triangle.b, triangle.c) + ) + if _cross_length_squared(*points) <= 1.0e-10: + degenerate += 1 + continue + rounded = tuple( + tuple(round(component, 6) for component in point) + for point in points + ) + position_key = tuple( + sorted( + rounded + ) + ) + oriented_key = min( + rounded, + (rounded[1], rounded[2], rounded[0]), + (rounded[2], rounded[0], rounded[1]), + ) + if oriented_key in seen_oriented: + same_wound_duplicates += 1 + continue + if position_key in seen_positions: + retained_reverse_wound += 1 + seen_positions.add(position_key) + seen_oriented.add(oriented_key) + if triangle.edge_codes is None: + raise RuntimeError( + "retail collision triangle omits native edge codes" + ) + record_hash = int.from_bytes( + hashlib.sha256( + _canonical_oriented_record(points) + ).digest(), + "big", + ) + expected_xor[surface] ^= record_hash + expected_sum[surface] = ( + expected_sum[surface] + record_hash + ) & ((1 << 256) - 1) + expected_counts[surface] += 1 + edge_hash = int.from_bytes( + hashlib.sha256( + _canonical_oriented_edge_record( + points, + triangle.edge_codes, + ) + ).digest(), + "big", + ) + expected_edge_xor ^= edge_hash + expected_edge_sum = ( + expected_edge_sum + edge_hash + ) & ((1 << 256) - 1) + + actual_xor: dict[int, int] = defaultdict(int) + actual_sum: dict[int, int] = defaultdict(int) + actual_counts: dict[int, int] = defaultdict(int) + actual_edge_xor = 0 + actual_edge_sum = 0 + actual = analysis["_collision_bytes"] + record_size = 48 if int(analysis["version"]) >= 11 else 44 + if record_size != 48: + raise RuntimeError( + "University package format does not preserve native collision " + "edge codes" + ) + for offset in range(0, len(actual), record_size): + values = struct.unpack_from("<9fII4B", actual, offset) + material_id = values[10] + material = material_by_id.get(material_id) + if material is None: + raise RuntimeError( + f"collision triangle references missing material {material_id}" + ) + surface = ( + int(material["audio_surface"]) + | (int(material["physics_surface"]) << 7) + | (int(material["surface_pattern"]) << 12) + ) + if surface not in by_surface: + raise RuntimeError( + f"collision triangle uses non-retail surface 0x{surface:04X}" + ) + points = ( + tuple(values[0:3]), + tuple(values[3:6]), + tuple(values[6:9]), + ) + record_hash = int.from_bytes( + hashlib.sha256(_canonical_oriented_record(points)).digest(), + "big", + ) + actual_xor[surface] ^= record_hash + actual_sum[surface] = ( + actual_sum[surface] + record_hash + ) & ((1 << 256) - 1) + actual_counts[surface] += 1 + edge_codes = tuple(values[11:14]) + if values[14] != 1: + raise RuntimeError( + "University package collision triangle omits its native " + "edge-code marker" + ) + edge_hash = int.from_bytes( + hashlib.sha256( + _canonical_oriented_edge_record(points, edge_codes) + ).digest(), + "big", + ) + actual_edge_xor ^= edge_hash + actual_edge_sum = ( + actual_edge_sum + edge_hash + ) & ((1 << 256) - 1) + + mismatches = [ + surface + for surface in surfaces + if actual_counts[surface] != expected_counts[surface] + or actual_xor[surface] != expected_xor[surface] + or actual_sum[surface] != expected_sum[surface] + ] + if mismatches: + surface = mismatches[0] + raise RuntimeError( + "SKATE collision differs from decoded retail surface " + f"0x{surface:04X}: actual_count={actual_counts[surface]} " + f"expected_count={expected_counts[surface]} " + f"actual_xor={actual_xor[surface]:064x} " + f"expected_xor={expected_xor[surface]:064x}" + ) + if ( + actual_edge_xor != expected_edge_xor + or actual_edge_sum != expected_edge_sum + ): + raise RuntimeError( + "SKATE collision native edge codes differ from decoded retail " + f"metadata: actual_xor={actual_edge_xor:064x} " + f"expected_xor={expected_edge_xor:064x}" + ) + canonical_digest = hashlib.sha256() + for surface in surfaces: + canonical_digest.update(struct.pack(" None: + encoded = value.encode("utf-8") + output.extend(struct.pack(" bytes: + records: list[tuple[str, dict[str, object]]] = [] + for rail in manifest.get("grind_splines", []): + name = ( + f"GRIND_{str(rail['asset_id'])[2:]}_" + f"{int(rail['rail_index']):04d}" + ) + records.append((name, rail)) + records.sort(key=lambda record: record[0]) + names = [record[0] for record in records] + if len(names) != len(set(names)): + raise ValueError("University retail grind object names are not unique") + + output = bytearray() + for name, rail in records: + payloads = rail["native_segment_payloads"] + segment_count = int(rail["segment_count"]) + if len(payloads) != segment_count: + raise ValueError(f"{name} has an inconsistent segment count") + _write_string(output, name) + output.extend( + struct.pack( + "30I", payload) + output.extend(struct.pack("<30I", *words)) + return bytes(output) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + args = parser.parse_args() + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + expected = expected_grind_section(manifest) + analysis = analyze_package(args.package) + actual_hash = analysis["integrity"]["grind_rails_sha256"] + expected_hash = hashlib.sha256(expected).hexdigest() + expected_rails = len(manifest.get("grind_splines", [])) + expected_segments = sum( + int(rail["segment_count"]) + for rail in manifest.get("grind_splines", []) + ) + if analysis["counts"]["grind_rails"] != expected_rails: + raise RuntimeError("SKATE grind rail count differs from the manifest") + if analysis["counts"]["native_grind_segments"] != expected_segments: + raise RuntimeError( + "SKATE native grind segment count differs from the manifest" + ) + if actual_hash != expected_hash: + raise RuntimeError( + "SKATE native grind bytes differ from the retail manifest: " + f"actual={actual_hash} expected={expected_hash}" + ) + print( + json.dumps( + { + "status": "UNIVERSITY_GRINDS_EXACT", + "rails": expected_rails, + "segments": expected_segments, + "bytes": len(expected), + "sha256": expected_hash, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_lightmaps.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_lightmaps.py new file mode 100644 index 00000000..5d02e6e2 --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_lightmaps.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Verify exact retail University lightmaps through the SKATE package.""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import struct +import sys + +import numpy +from PIL import Image + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import Reader, VERTEX_BYTES_V12 # noqa: E402 + + +def _selected_lightmaps( + manifest: dict[str, object], +) -> tuple[list[dict[str, object]], set[str], int, int]: + selected: list[dict[str, object]] = [] + excluded_no_uv = 0 + excluded_shader = 0 + for model in manifest["models"]: + for mesh in model["meshes"]: + lightmap_id = str( + mesh.get("retail_texture_ids", {}).get("lightmap", "") + ) + if not lightmap_id: + continue + if not mesh.get("lightmap_uv"): + excluded_no_uv += 1 + continue + shader = str(mesh.get("shader_name") or "") + if shader.startswith(("water.", "ocean.")): + excluded_shader += 1 + continue + if not mesh.get("texture_id"): + raise RuntimeError( + "a selected lightmapped mesh has no base texture/material" + ) + selected.append(mesh) + return ( + selected, + { + str(mesh["retail_texture_ids"]["lightmap"]) + for mesh in selected + }, + excluded_no_uv, + excluded_shader, + ) + + +def _png_rgba_blender_order(path: Path) -> bytes: + with Image.open(path) as image: + rgba = image.convert("RGBA") + width, height = rgba.size + top_to_bottom = rgba.tobytes() + row_bytes = width * 4 + # Blender exposes image.pixels bottom row first. The importer flips V + # when it converts retail UVs, so this storage-order flip preserves + # the exact sampled texel rather than altering the map orientation. + return b"".join( + top_to_bottom[row * row_bytes : (row + 1) * row_bytes] + for row in range(height - 1, -1, -1) + ) + + +def _source_uv_bounds( + manifest: dict[str, object], +) -> tuple[tuple[float, float], tuple[float, float]]: + minimum = numpy.array((numpy.inf, numpy.inf), dtype=numpy.float64) + maximum = numpy.array((-numpy.inf, -numpy.inf), dtype=numpy.float64) + root = Path(manifest["_manifest_root"]) + for model in manifest["models"]: + with numpy.load(root / str(model["npz"])) as arrays: + for mesh in model["meshes"]: + lightmap_id = str( + mesh.get("retail_texture_ids", {}).get("lightmap", "") + ) + shader = str(mesh.get("shader_name") or "") + if ( + not lightmap_id + or not mesh.get("lightmap_uv") + or shader.startswith(("water.", "ocean.")) + ): + continue + values = numpy.abs( + arrays[f"lightmap_uvs_{int(mesh['index'])}"].astype( + numpy.float64 + ) + ) + values[:, 1] = 1.0 - values[:, 1] + minimum = numpy.minimum(minimum, values.min(axis=0)) + maximum = numpy.maximum(maximum, values.max(axis=0)) + if not numpy.isfinite(minimum).all() or not numpy.isfinite(maximum).all(): + raise RuntimeError("could not derive retail lightmap UV bounds") + return tuple(minimum), tuple(maximum) + + +def _verify_package( + package_path: Path, + manifest: dict[str, object], + selected_ids: set[str], + source_uv_bounds: tuple[tuple[float, float], tuple[float, float]], +) -> dict[str, int]: + reader = Reader(package_path.read_bytes()) + magic = reader.take(8, "magic") + if magic not in (b"SKATE12\0", b"SKATE13\0", b"SKATE14\0"): + raise RuntimeError(f"expected SKATE12/13 package, found {magic!r}") + if reader.u32("endian marker") != 0x12345678: + raise RuntimeError("invalid SKATE endian marker") + reader.string("map name") + reader.skip(49 * 4, "map metadata") + counts = struct.unpack("<9I", reader.take(9 * 4, "count table")) + material_count, texture_count, vertex_count, index_count = counts[:4] + + materials: list[tuple[int, int, float]] = [] + for index in range(material_count): + reader.string(f"material {index} name") + fields = reader.take(76, f"material {index} fields") + materials.append( + ( + struct.unpack_from(" 1.0e-5 + or abs(package_uv_max[axis] - expected_max[axis]) > 1.0e-5 + ): + raise RuntimeError( + "package lightmap UV bounds changed on axis " + f"{axis}: package=({package_uv_min[axis]}, " + f"{package_uv_max[axis]}) source=({expected_min[axis]}, " + f"{expected_max[axis]})" + ) + + return { + "package_lightmap_texture_ids": len(package_lightmap_names), + "package_lightmap_materials": len(lightmap_material_ids), + "package_lightmap_strength": min(lightmap_strengths), + "exact_payloads": exact_payloads, + "exact_payload_bytes": exact_bytes, + "package_lightmap_vertices": lightmap_vertices, + "package_distinct_uv_vertices": distinct_uv_vertices, + "package_lightmap_triangle_corners": lightmap_corners, + "package_lightmap_uv_min": package_uv_min, + "package_lightmap_uv_max": package_uv_max, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + parser.add_argument("--expected", type=Path) + args = parser.parse_args() + + manifest_path = args.manifest.resolve() + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["_manifest_root"] = str(manifest_path.parent) + source_uv_bounds = _source_uv_bounds(manifest) + selected, selected_ids, excluded_no_uv, excluded_shader = ( + _selected_lightmaps(manifest) + ) + source_references = sum( + bool(mesh.get("retail_texture_ids", {}).get("lightmap")) + for model in manifest["models"] + for mesh in model["meshes"] + ) + result = { + "status": "UNIVERSITY_RETAIL_LIGHTMAPS_VERIFIED", + "source_lightmap_references": source_references, + "source_lightmap_texture_ids": len( + { + str(mesh["retail_texture_ids"]["lightmap"]) + for model in manifest["models"] + for mesh in model["meshes"] + if mesh.get("retail_texture_ids", {}).get("lightmap") + } + ), + "selected_mesh_parts": len(selected), + "excluded_no_secondary_uv": excluded_no_uv, + "excluded_shader_mesh_parts": excluded_shader, + **_verify_package( + args.package.resolve(), + manifest, + selected_ids, + source_uv_bounds, + ), + } + if args.expected is not None: + expected = json.loads( + args.expected.resolve().read_text(encoding="utf-8") + )["retail_lightmaps"] + for name, value in result.items(): + if name == "status": + continue + if expected[name] != value: + raise RuntimeError( + f"retail lightmap contract {name} changed: " + f"{value!r} != {expected[name]!r}" + ) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_material_channels.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_material_channels.py new file mode 100644 index 00000000..0b09d43a --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_material_channels.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Verify University normal and lightmap roles through the SKATE package.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import struct +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import ( # noqa: E402 + VERTEX_BYTES, + VERTEX_BYTES_V12, + analyze_package, +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + parser.add_argument("--expected", type=Path) + args = parser.parse_args() + + manifest = json.loads(args.manifest.resolve().read_text(encoding="utf-8")) + excluded = { + str(texture_id).lower() + for texture_id in manifest["normal_texture_policy"][ + "excluded_texture_ids" + ] + } + source_normal_ids: set[str] = set() + selected_normal_ids: set[str] = set() + source_normal_references = 0 + selected_mesh_parts = 0 + for model in manifest["models"]: + for mesh in model["meshes"]: + normal_id = str( + mesh.get("retail_texture_ids", {}).get("normal", "") + ).lower() + if not normal_id: + continue + source_normal_references += 1 + source_normal_ids.add(normal_id) + if normal_id not in excluded: + selected_mesh_parts += 1 + selected_normal_ids.add(normal_id) + + analysis = analyze_package(args.package.resolve(), include_payloads=True) + textures_by_id = { + int(texture["id"]): texture + for texture in analysis["texture_dimensions"] + } + materials = analysis["_materials"] + normal_materials = [ + material + for material in materials + if int(material["normal_texture"]) != 0 + ] + package_normal_ids: set[str] = set() + for material in normal_materials: + texture_id = int(material["normal_texture"]) + texture = textures_by_id.get(texture_id) + if texture is None: + raise RuntimeError( + f"material {material['name']!r} references missing normal " + f"texture {texture_id}" + ) + if int(texture["color_space"]) != 0: + raise RuntimeError( + f"normal texture {texture['name']!r} is not linear" + ) + package_normal_ids.add(str(texture["name"]).lower()) + + if package_normal_ids != selected_normal_ids: + missing = sorted(selected_normal_ids - package_normal_ids) + extra = sorted(package_normal_ids - selected_normal_ids) + raise RuntimeError( + "SKATE normal texture set differs from conservative retail " + f"selection: missing={missing}, extra={extra}" + ) + if package_normal_ids & excluded: + raise RuntimeError("SKATE package binds an excluded special normal map") + + unexpected_roles = { + role: sum(int(material[role]) != 0 for material in materials) + for role in ( + "lightmap_texture", + "orm_texture", + "emissive_texture", + ) + } + if unexpected_roles["orm_texture"] or unexpected_roles[ + "emissive_texture" + ]: + raise RuntimeError( + "University unexpectedly populated an unsupported material " + f"role: {unexpected_roles}" + ) + + used_material_ids: set[int] = set() + vertex_bytes = analysis["_vertex_bytes"] + vertex_stride = ( + VERTEX_BYTES_V12 + if int(analysis["version"]) >= 12 + else VERTEX_BYTES + ) + for offset in range(0, len(vertex_bytes), vertex_stride): + used_material_ids.add( + struct.unpack_from(" int: + text = str(value or "").strip() + return int(text, 0) if text else 0 + + +def _family(shader_name: str) -> int: + shader = shader_name.lower() + prefixes = ( + ("environment.reflective_simple", 6), + ("environment.reflective_trans", 13), + ("environment.reflective", 5), + ("environment.decal_tileable", 4), + ("environment.decal", 3), + ("environment.default", 1), + ("environmentsimple.alphatest", 7), + ("environmentsimple.diffuse", 8), + ("environmentsimple.default", 2), + ("tree.default", 9), + ("animated.tree", 10), + ("proxyworld.", 11), + ("incandescent.backlituvscroll", 14), + ("incandescent.default", 12), + ("water.flowing", 30), + ("ocean.default", 31), + ("ocean.reflection", 32), + ("sky.", 40), + ) + return next( + (family for prefix, family in prefixes if shader.startswith(prefix)), + 0, + ) + + +def _flags(shader_name: str, alpha_mode: int) -> int: + shader = shader_name.lower() + flags = 1 if alpha_mode == 1 else (2 if alpha_mode == 2 else 0) + if shader.startswith(("tree.", "animated.tree")) or "alphatest" in shader: + flags |= 1 | 4 + if shader.startswith("sky."): + flags |= 8 + if shader.startswith("environment.decal"): + flags |= 16 + if shader.startswith("environment.decal_tileable"): + flags |= 32 + if shader.startswith(("water.", "ocean.")): + flags |= 64 + return flags + + +def _source_metadata( + model: dict[str, object], + mesh: dict[str, object], +) -> dict[str, object]: + return { + "asset_id": model["asset_id"], + "stream_file": model["stream_file"], + "mesh_index": mesh["index"], + "vertex_stride": mesh["vertex_stride"], + "attributes": mesh.get("attributes", []), + "source_offsets": mesh["source_offsets"], + "bounds": mesh.get("bounds", {}), + "vertex_count": mesh["vertex_count"], + "triangle_count": mesh["triangle_count"], + "retail_world_frame": mesh.get("retail_world_frame"), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + args = parser.parse_args() + + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + package = analyze_package(args.package, include_payloads=True) + if package["version"] not in (12, 13): + raise RuntimeError( + f"expected SKATE v12 or v13, found v{package['version']}" + ) + + textures = { + int(entry["id"]): str(entry["name"]) + for entry in package["texture_dimensions"] + } + expected: dict[tuple[str, int], tuple[dict[str, object], dict[str, object]]] = {} + for model in manifest["models"]: + for mesh in model["meshes"]: + key = (str(model["asset_id"]), int(mesh["index"])) + if key in expected: + raise RuntimeError(f"duplicate manifest material identity {key}") + expected[key] = (model, mesh) + + actual: dict[tuple[str, int], dict[str, object]] = {} + disabled = 0 + for material in package["_materials"]: + if not material.get("retail_enabled", False): + disabled += 1 + continue + source_text = str(material.get("retail_source_metadata", "")) + try: + source = json.loads(source_text) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"material {material['name']!r} has invalid source metadata" + ) from exc + key = (str(source.get("asset_id", "")), int(source.get("mesh_index", -1))) + if key in actual: + raise RuntimeError(f"duplicate package material identity {key}") + material["_decoded_source"] = source + actual[key] = material + + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + if missing or extra: + raise RuntimeError( + "retail material identity set changed: " + f"missing={missing[:5]} extra={extra[:5]}" + ) + + shader_counts: Counter[str] = Counter() + role_counts: Counter[str] = Counter() + parameter_counts: Counter[str] = Counter() + for key, (model, mesh) in expected.items(): + material = actual[key] + shader = str(mesh.get("shader_name") or "") + shader_counts[shader] += 1 + checks = { + "retail_material_guid": _integer( + mesh.get("retail_material_guid") + ), + "retail_material_handle": _integer( + mesh.get("retail_material_handle") + ), + "retail_material_group_index": int( + mesh.get("retail_material_group_index", -1) + ), + "retail_shader_name": shader, + "retail_shader_family": _family(shader), + "retail_render_flags": _flags( + shader, int(mesh.get("alpha_mode", 0)) + ), + } + for field, expected_value in checks.items(): + if material.get(field) != expected_value: + raise RuntimeError( + f"{key} changed {field}: " + f"{material.get(field)!r} != {expected_value!r}" + ) + + expected_parameters = { + str(name): [str(value) for value in values] + for name, values in mesh.get("retail_parameters", {}).items() + } + if material.get("retail_parameters") != expected_parameters: + raise RuntimeError(f"{key} changed retail parameter values") + parameter_counts.update(expected_parameters.keys()) + + expected_textures = { + str(role): str(texture_id).lower() + for role, texture_id in mesh.get( + "retail_texture_ids", {} + ).items() + } + bindings = {} + for binding in material.get("retail_texture_bindings", []): + role = str(binding["semantic"]) + texture_name = textures.get(int(binding["texture"])) + if texture_name is None: + raise RuntimeError(f"{key} binding {role!r} has no texture") + bindings[role] = texture_name + expected_uv = 1 if role in {"lightmap", "alpha"} else ( + 2 if role == "decal" else 0 + ) + tileable = shader.lower().startswith( + "environment.decal_tileable" + ) + expected_address = 1 if role == "lightmap" or ( + role == "decal" and not tileable + ) else 0 + if ( + int(binding["uv_set"]) != expected_uv + or int(binding["address_u"]) != expected_address + or int(binding["address_v"]) != expected_address + ): + raise RuntimeError(f"{key} changed {role!r} sampler binding") + if bindings != expected_textures: + raise RuntimeError( + f"{key} changed texture roles: " + f"{bindings!r} != {expected_textures!r}" + ) + role_counts.update(expected_textures.keys()) + + if material["_decoded_source"] != _source_metadata(model, mesh): + raise RuntimeError(f"{key} changed source provenance") + + summary = { + "status": "UNIVERSITY_RETAIL_MATERIALS_EXACT", + "retail_materials": len(actual), + "non_retail_collision_materials": disabled, + "shader_families": dict(sorted(shader_counts.items())), + "texture_role_references": dict(sorted(role_counts.items())), + "parameter_name_references": dict(sorted(parameter_counts.items())), + "world_metadata_extension": "WMET" in package["extension_tags"], + } + if not summary["world_metadata_extension"]: + raise RuntimeError("package lost the WMET retail world manifest") + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_world_frames.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_world_frames.py new file mode 100644 index 00000000..6483558d --- /dev/null +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/verify_university_world_frames.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Verify exact retail world tangent frames through a University SKATE export.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import struct +import sys + +import numpy + + +REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT / "tools" / "blender_owned_map")) + +from analyze_skate import ( # noqa: E402 + VERTEX_BYTES_V12, + analyze_package, +) + + +def _source_key(source: dict[str, object]) -> tuple[str, int]: + return str(source.get("asset_id", "")), int( + source.get("mesh_index", -1) + ) + + +def _source_frame_digests( + manifest: dict[str, object], + root: Path, +) -> tuple[dict[tuple[str, int], tuple[int, str]], int]: + result: dict[tuple[str, int], tuple[int, str]] = {} + source_vertices = 0 + for model in manifest["models"]: + with numpy.load(root / str(model["npz"])) as arrays: + for mesh in model["meshes"]: + if not mesh.get("retail_world_frame"): + continue + mesh_index = int(mesh["index"]) + key = (str(model["asset_id"]), mesh_index) + faces = numpy.asarray( + arrays[f"faces_{mesh_index}"], dtype=numpy.uint32 + ) + corners = faces.reshape(-1) + normals = numpy.asarray( + arrays[f"retail_normals_{mesh_index}"], + dtype=numpy.float32, + )[corners].copy() + # Coordinate-system round trips can turn +0 into -0 without + # changing the represented vector. Canonicalize signed zero + # before the byte-level comparison. + normals[normals == 0.0] = 0.0 + tangent_key = ( + f"retail_tangents_{mesh_index}" + if f"retail_tangents_{mesh_index}" in arrays + else f"retail_binormals_{mesh_index}" + ) + tangents = numpy.asarray( + arrays[tangent_key], + dtype=numpy.float32, + )[corners].astype(numpy.float64) + handedness = numpy.asarray( + arrays[f"retail_tangent_handedness_{mesh_index}"], + dtype=numpy.float32, + )[corners] + tangent_lengths = numpy.linalg.norm(tangents, axis=1) + tangents -= normals * numpy.sum( + tangents * normals, axis=1 + )[:, None] + projected_lengths = numpy.linalg.norm(tangents, axis=1) + valid = projected_lengths > numpy.maximum( + 1.0e-12, tangent_lengths * 1.0e-6 + ) + tangents[valid] /= projected_lengths[valid, None] + handedness = handedness.copy() + handedness[~valid] = 0.0 + binormals = ( + numpy.cross(normals, tangents) + * handedness[:, None] + ) + packed_binormals = numpy.rint( + numpy.clip(binormals, -1.0, 1.0) * 127.0 + ).astype(numpy.int8) + packed_handedness = numpy.rint( + numpy.clip(handedness, -1.0, 1.0) * 127.0 + ).astype(numpy.int8) + records = numpy.empty( + len(corners), + dtype=numpy.dtype( + [ + ("normal", " dict[tuple[str, int], tuple[int, str]]: + if int(package["version"]) not in (12, 13): + raise RuntimeError( + f"expected SKATE v12 or v13, found v{package['version']}" + ) + material_keys: dict[int, tuple[str, int]] = {} + for material_id, material in enumerate( + package["_materials"], start=1 + ): + if not material.get("retail_enabled", False): + continue + source_text = str(material.get("retail_source_metadata", "")) + source = json.loads(source_text) + if source.get("retail_world_frame"): + material_keys[material_id] = _source_key(source) + + digests = { + key: hashlib.sha256() for key in material_keys.values() + } + counts = {key: 0 for key in material_keys.values()} + vertex_bytes = package["_vertex_bytes"] + for vertex_index in package["_indices"]: + offset = int(vertex_index) * VERTEX_BYTES_V12 + material_id = struct.unpack_from(" str: + digest = hashlib.sha256() + for key in sorted(entries): + asset_id, mesh_index = key + count, frame_digest = entries[key] + digest.update(asset_id.encode("ascii")) + digest.update(struct.pack(" int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path) + parser.add_argument("package", type=Path) + parser.add_argument("--expected", type=Path) + args = parser.parse_args() + + manifest_path = args.manifest.resolve() + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + source, source_vertices = _source_frame_digests( + manifest, manifest_path.parent + ) + package = analyze_package(args.package.resolve(), include_payloads=True) + exported = _package_frame_digests(package) + missing = sorted(set(source) - set(exported)) + extra = sorted(set(exported) - set(source)) + if missing or extra: + raise RuntimeError( + "retail world-frame material set changed: " + f"missing={missing[:5]} extra={extra[:5]}" + ) + mismatches = [ + (key, source[key], exported[key]) + for key in sorted(source) + if source[key] != exported[key] + ] + if mismatches: + raise RuntimeError( + "retail world-frame corner data changed; first mismatch: " + f"{mismatches[0]!r}" + ) + + result = { + "status": "UNIVERSITY_RETAIL_WORLD_FRAMES_RECONSTRUCTED", + "mesh_parts": len(source), + "source_vertices": source_vertices, + "triangle_corners": sum(count for count, _digest in source.values()), + "expanded_frame_sha256": _aggregate_digest(source), + } + if args.expected: + expected = json.loads( + args.expected.read_text(encoding="utf-8") + ).get("retail_world_frames") + if expected is None: + raise RuntimeError( + "expected manifest has no retail_world_frames section" + ) + for name, value in result.items(): + if name == "status": + continue + if expected.get(name) != value: + raise RuntimeError( + f"retail world-frame {name} changed: " + f"{value!r} != {expected.get(name)!r}" + ) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vendor/utt/LICENSE b/tools/vendor/utt/LICENSE new file mode 100644 index 00000000..cbd595e2 --- /dev/null +++ b/tools/vendor/utt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 duckyinnit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/vendor/utt/mdl_parser/__init__.py b/tools/vendor/utt/mdl_parser/__init__.py new file mode 100644 index 00000000..0f0e635b --- /dev/null +++ b/tools/vendor/utt/mdl_parser/__init__.py @@ -0,0 +1,33 @@ +from .errors import PSGDataError, PSGError, PSGFormatError +from .model import Bone, MaterialParameter, Mesh, PSGModel, VertexAttribute +from .parser import ( + is_psg_model, + is_rx2_model, + load_model, + load_psg, + load_rx2, + parse_model, + parse_psg, + parse_rx2, +) + +__version__ = "1.0.0" + +__all__ = [ + "Bone", + "MaterialParameter", + "Mesh", + "PSGDataError", + "PSGError", + "PSGFormatError", + "PSGModel", + "VertexAttribute", + "is_psg_model", + "is_rx2_model", + "load_model", + "load_psg", + "load_rx2", + "parse_model", + "parse_psg", + "parse_rx2", +] diff --git a/tools/vendor/utt/mdl_parser/errors.py b/tools/vendor/utt/mdl_parser/errors.py new file mode 100644 index 00000000..729e8d37 --- /dev/null +++ b/tools/vendor/utt/mdl_parser/errors.py @@ -0,0 +1,10 @@ +class PSGError(Exception): + """Base class for PSG parsing failures.""" + + +class PSGFormatError(PSGError): + """Raised when a file is not a supported EA Skate PS3 model PSG.""" + + +class PSGDataError(PSGError): + """Raised when a supported PSG contains invalid or truncated data.""" diff --git a/tools/vendor/utt/mdl_parser/model.py b/tools/vendor/utt/mdl_parser/model.py new file mode 100644 index 00000000..9d0d31ca --- /dev/null +++ b/tools/vendor/utt/mdl_parser/model.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import numpy as np + + +@dataclass(slots=True, frozen=True) +class MaterialParameter: + kind: str + value: str + + +@dataclass(slots=True, frozen=True) +class VertexAttribute: + semantic: str + offset: int + data_type: str + components: int + descriptor: bytes + + +@dataclass(slots=True, frozen=True) +class Bone: + name: str + matrix: np.ndarray + skeleton_index: int = 0 + + +@dataclass(slots=True) +class Mesh: + name: str + vertices: np.ndarray + faces: np.ndarray + uvs: np.ndarray | None = None + normals: np.ndarray | None = None + material_name: str | None = None + vertex_stride: int = 0 + attributes: tuple[VertexAttribute, ...] = () + source_offsets: dict[str, int] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.vertices = np.ascontiguousarray(self.vertices, dtype=np.float32) + self.faces = np.ascontiguousarray(self.faces, dtype=np.uint32) + if self.uvs is not None: + self.uvs = np.ascontiguousarray(self.uvs, dtype=np.float32) + + if self.vertices.ndim != 2 or self.vertices.shape[1] != 3: + raise ValueError("vertices must have shape (N, 3)") + if self.faces.ndim != 2 or self.faces.shape[1] != 3: + raise ValueError("faces must have shape (N, 3)") + if self.uvs is not None and (self.uvs.ndim != 2 or self.uvs.shape[1] != 2): + raise ValueError("uvs must have shape (N, 2)") + + @property + def vertex_count(self) -> int: + return int(self.vertices.shape[0]) + + @property + def triangle_count(self) -> int: + return int(self.faces.shape[0]) + + @property + def bounds(self) -> tuple[np.ndarray, np.ndarray]: + if self.vertex_count == 0: + zero = np.zeros(3, dtype=np.float32) + return zero.copy(), zero.copy() + return self.vertices.min(axis=0), self.vertices.max(axis=0) + + +@dataclass(slots=True) +class PSGModel: + meshes: list[Mesh] + bones: list[Bone] = field(default_factory=list) + materials: list[MaterialParameter] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + source_path: Path | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def vertex_count(self) -> int: + return sum(mesh.vertex_count for mesh in self.meshes) + + @property + def triangle_count(self) -> int: + return sum(mesh.triangle_count for mesh in self.meshes) + + @property + def bounds(self) -> tuple[np.ndarray, np.ndarray]: + populated = [mesh for mesh in self.meshes if mesh.vertex_count] + if not populated: + zero = np.zeros(3, dtype=np.float32) + return zero.copy(), zero.copy() + + minimums = np.stack([mesh.bounds[0] for mesh in populated], axis=0) + maximums = np.stack([mesh.bounds[1] for mesh in populated], axis=0) + return minimums.min(axis=0), maximums.max(axis=0) + + @property + def center(self) -> np.ndarray: + minimum, maximum = self.bounds + return (minimum + maximum) * 0.5 + + @property + def radius(self) -> float: + minimum, maximum = self.bounds + return float(np.linalg.norm(maximum - minimum) * 0.5) + + def summary(self) -> str: + source = self.source_path.name if self.source_path else "" + return ( + f"{source}: {len(self.meshes)} mesh(es), " + f"{self.vertex_count:,} vertices, {self.triangle_count:,} triangles, " + f"{len(self.bones)} bone(s)" + ) diff --git a/tools/vendor/utt/mdl_parser/parser.py b/tools/vendor/utt/mdl_parser/parser.py new file mode 100644 index 00000000..a3763a70 --- /dev/null +++ b/tools/vendor/utt/mdl_parser/parser.py @@ -0,0 +1,828 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import numpy as np + +from .errors import PSGDataError, PSGFormatError +from .model import Bone, MaterialParameter, Mesh, PSGModel, VertexAttribute + +MAGIC_PS3 = b"\x89RW4ps3" +MODEL_TYPE_PS3 = b"\x00\x00\x00\x10" +MAGIC_X360 = b"\x89RW4xb2" +MODEL_TYPE_X360 = b"\x00\x00\x00\x04" +MODEL_TYPE_X360_NHL = b"\x00\x00\x08\x00" + +TOC_RECORD_SIZE = 24 +TYPE_VERTEX = b"\x00\x02\x00\xEA" +TYPE_FACE = b"\x00\x02\x00\xEB" +TYPE_MESH_INFO = b"\x00\x02\x00\xE9" +TYPE_MATERIAL = b"\x00\xEB\x00\x05" +TYPE_BONES = b"\x00\xEB\x00\x01" + + +@dataclass(slots=True, frozen=True) +class _TOCRecord: + index: int + offset: int + values: tuple[int, int, int, int, int] + resource_type: bytes + + +@dataclass(slots=True, frozen=True) +class _BufferResource: + offset: int + size: int + info_offset: int + toc_index: int + + +@dataclass(slots=True, frozen=True) +class _MeshLayout: + stride: int + position: VertexAttribute + uv1: VertexAttribute | None + attributes: tuple[VertexAttribute, ...] + + +_DTYPE_MAP: dict[str, np.dtype] = { + "float32": np.dtype(">f4"), + "float16": np.dtype(">f2"), + "int16": np.dtype(">i2"), + "uint16": np.dtype(">u2"), +} + + +def is_psg_model(data: bytes | bytearray | memoryview) -> bool: + view = memoryview(data) + return ( + len(view) >= 0x74 + and bytes(view[:7]) == MAGIC_PS3 + and bytes(view[0x70:0x74]) == MODEL_TYPE_PS3 + ) + + +def is_rx2_model(data: bytes | bytearray | memoryview) -> bool: + view = memoryview(data) + return ( + len(view) >= 0x5C + and bytes(view[:7]) == MAGIC_X360 + and bytes(view[0x58:0x5C]) in (MODEL_TYPE_X360, MODEL_TYPE_X360_NHL) + ) + + +def load_psg(path: str | Path, *, strict: bool = False) -> PSGModel: + source_path = Path(path) + return parse_psg(source_path.read_bytes(), source_path=source_path, strict=strict) + + +def load_rx2(path: str | Path, *, strict: bool = False) -> PSGModel: + source_path = Path(path) + return parse_rx2(source_path.read_bytes(), source_path=source_path, strict=strict) + + +def load_model(path: str | Path, *, strict: bool = False) -> PSGModel: + """Load a model PSG or RX2, auto-detecting the platform by magic.""" + source_path = Path(path) + data = source_path.read_bytes() + if is_rx2_model(data): + return parse_rx2(data, source_path=source_path, strict=strict) + return parse_psg(data, source_path=source_path, strict=strict) + + +def parse_psg( + data: bytes | bytearray | memoryview, + *, + source_path: str | Path | None = None, + strict: bool = False, +) -> PSGModel: + return _parse_model(data, source_path=source_path, strict=strict, platform="ps3") + + +def parse_rx2( + data: bytes | bytearray | memoryview, + *, + source_path: str | Path | None = None, + strict: bool = False, +) -> PSGModel: + return _parse_model(data, source_path=source_path, strict=strict, platform="xbx") + + +def parse_model( + data: bytes | bytearray | memoryview, + *, + source_path: str | Path | None = None, + strict: bool = False, +) -> PSGModel: + """Parse a model PSG or RX2, auto-detecting the platform by magic.""" + if is_rx2_model(data): + return parse_rx2(data, source_path=source_path, strict=strict) + return parse_psg(data, source_path=source_path, strict=strict) + + +def _parse_model( + data: bytes | bytearray | memoryview, + *, + source_path: str | Path | None = None, + strict: bool = False, + platform: str = "ps3", +) -> PSGModel: + raw = bytes(data) + if platform == "xbx": + if len(raw) < 0x5C: + raise PSGFormatError("File is too small to contain an RX2 header") + if raw[:7] != MAGIC_X360: + raise PSGFormatError("Not an EA RenderWare4 Xbox 360 file") + type_ok = raw[0x58:0x5C] in (MODEL_TYPE_X360, MODEL_TYPE_X360_NHL) + type_offset = 0x58 + type_field = raw[0x58:0x5C] + else: + if len(raw) < 0x74: + raise PSGFormatError("File is too small to contain a PSG header") + if raw[:7] != MAGIC_PS3: + raise PSGFormatError("Not an EA RenderWare4 PS3 file") + type_ok = raw[0x70:0x74] == MODEL_TYPE_PS3 + type_offset = 0x70 + type_field = raw[0x70:0x74] + if not type_ok: + raise PSGFormatError( + f"{'X360' if platform == 'xbx' else 'PS3'} file is not a model " + f"(type {type_field.hex(' ') or ''})" + ) + + warnings: list[str] = [] + file_count = _read_i32(raw, 0x20, "file count") + file_table = _read_i32(raw, 0x30, "file table offset") + header_size = _read_i32(raw, 0x44, "header size") + + if not 0 < file_count <= 1_000_000: + raise PSGDataError(f"Invalid PSG table record count: {file_count}") + if file_table < 0: + raise PSGDataError(f"Invalid PSG table offset: {file_table}") + + table_end = file_table + file_count * TOC_RECORD_SIZE + if table_end > len(raw): + raise PSGDataError( + f"PSG table ends at 0x{table_end:X}, beyond file size 0x{len(raw):X}" + ) + + records = _read_toc(raw, file_table, file_count) + vertex_resources: list[_BufferResource] = [] + face_resources: list[_BufferResource] = [] + mesh_info_offsets: list[int] = [] + material_offsets: list[int] = [] + bone_offsets: list[int] = [] + + for record in records: + resource_type = record.resource_type + if resource_type in (TYPE_VERTEX, TYPE_FACE, TYPE_MATERIAL, TYPE_BONES): + if record.offset < TOC_RECORD_SIZE: + warnings.append( + f"TOC record {record.index} cannot access its preceding metadata block" + ) + continue + info = _unpack(raw, ">12i", record.offset - TOC_RECORD_SIZE, "TOC metadata") + else: + info = None + + next_paired_type = ( + records[record.index + 2].resource_type + if record.index + 2 < len(records) + else None + ) + + if resource_type == TYPE_VERTEX and info is not None: + if next_paired_type == TYPE_FACE: + vertex_resources.append( + _BufferResource(info[0], info[2], info[6], record.index) + ) + elif resource_type == TYPE_FACE and info is not None: + if next_paired_type != TYPE_FACE: + face_resources.append( + _BufferResource(info[0], info[2], info[6], record.index) + ) + elif resource_type == TYPE_MESH_INFO: + mesh_info_offsets.append(record.values[0]) + elif resource_type == TYPE_MATERIAL and info is not None: + material_offsets.append(info[6]) + elif resource_type == TYPE_BONES: + bone_offsets.append(record.values[0]) + + materials: list[MaterialParameter] = [] + mesh_names: list[str] = [] + diffuse_names: list[str] = [] + for material_offset in _deduplicate(material_offsets): + try: + parsed, names, diffuse = _parse_material_table(raw, material_offset) + materials.extend(parsed) + mesh_names.extend(names) + diffuse_names.extend(diffuse) + except PSGDataError as exc: + if strict: + raise + warnings.append(f"Material table at 0x{material_offset:X}: {exc}") + + paired_count = min( + len(vertex_resources), len(face_resources), len(mesh_info_offsets) + ) + if paired_count == 0: + raise PSGDataError( + "No complete vertex/face/mesh-info set was found in the PSG table" + ) + + counts = ( + len(vertex_resources), + len(face_resources), + len(mesh_info_offsets), + ) + if len(set(counts)) != 1: + warnings.append( + "Resource counts do not match: " + f"{counts[0]} vertex, {counts[1]} face, {counts[2]} mesh-info; " + f"using the first {paired_count} complete set(s)" + ) + + meshes: list[Mesh] = [] + for mesh_index in range(paired_count): + vertex_resource = vertex_resources[mesh_index] + face_resource = face_resources[mesh_index] + mesh_info_offset = mesh_info_offsets[mesh_index] + + try: + layout = _parse_mesh_layout(raw, mesh_info_offset, warnings, platform) + vertices, uvs = _parse_vertices( + raw, + vertex_resource, + header_size, + layout, + warnings, + mesh_index, + ) + faces = _parse_faces( + raw, + face_resource, + header_size, + len(vertices), + warnings, + mesh_index, + platform, + ) + except PSGDataError as exc: + if strict: + raise + warnings.append(f"Mesh {mesh_index}: {exc}; mesh skipped") + continue + + normals = _compute_vertex_normals(vertices, faces) + + meshes.append( + Mesh( + name=_name_for_mesh(mesh_names, mesh_index, paired_count), + vertices=vertices, + faces=faces, + uvs=uvs, + normals=normals, + material_name=_material_for_mesh( + diffuse_names, mesh_index, paired_count + ), + vertex_stride=layout.stride, + attributes=layout.attributes, + source_offsets={ + "mesh_info": mesh_info_offset, + "vertex_buffer": vertex_resource.offset + header_size, + "face_buffer": face_resource.offset + header_size, + }, + ) + ) + + if not meshes: + detail = warnings[-1] if warnings else "unknown parsing error" + raise PSGDataError(f"No renderable mesh could be parsed: {detail}") + + bones: list[Bone] = [] + for skeleton_index, bone_offset in enumerate(_deduplicate(bone_offsets)): + try: + bones.extend(_parse_bones(raw, bone_offset, skeleton_index, warnings)) + except PSGDataError as exc: + if strict: + raise + warnings.append(f"Skeleton at 0x{bone_offset:X}: {exc}") + + names_location_offset = None + names_location = None + if len(raw) >= 0x234: + names_location_offset = _read_i32(raw, 0x230, "names location offset") + names_location = names_location_offset + 0x220 + + return PSGModel( + meshes=meshes, + bones=bones, + materials=materials, + warnings=warnings, + source_path=Path(source_path) if source_path is not None else None, + metadata={ + "magic": MAGIC_X360 if platform == "xbx" else MAGIC_PS3, + "type": raw[type_offset:type_offset + 4], + "file_count": file_count, + "file_table": file_table, + "header_size": header_size, + "names_location_offset": names_location_offset, + "names_location": names_location, + "platform": platform, + }, + ) + + +def _read_toc(data: bytes, offset: int, count: int) -> list[_TOCRecord]: + records: list[_TOCRecord] = [] + for index in range(count): + record_offset = offset + index * TOC_RECORD_SIZE + values = _unpack(data, ">5i", record_offset, f"TOC record {index}") + records.append( + _TOCRecord( + index=index, + offset=record_offset, + values=values, + resource_type=data[record_offset + 20 : record_offset + 24], + ) + ) + return records + + +def _parse_material_table( + data: bytes, offset: int +) -> tuple[list[MaterialParameter], list[str], list[str]]: + header = _unpack(data, ">8i", offset, "material header") + material_count = header[1] + header_size = header[3] + params_size = header[4] + + if material_count < 0 or material_count > 100_000: + raise PSGDataError(f"invalid material count {material_count}") + if material_count == 0: + return [], [], [] + if header_size < 0 or params_size < header_size: + raise PSGDataError("invalid material table sizes") + + payload_size = params_size - header_size + if payload_size % material_count: + raise PSGDataError( + f"parameter area {payload_size} is not divisible by {material_count}" + ) + block_size = payload_size // material_count + if block_size not in (24, 32): + raise PSGDataError(f"unsupported material parameter size {block_size}") + + parameters: list[MaterialParameter] = [] + mesh_names: list[str] = [] + diffuse_names: list[str] = [] + cursor = offset + header_size + + for index in range(material_count): + if block_size == 32: + values = _unpack(data, ">8i", cursor, f"material parameter {index}") + type_offset = values[0] + value_offset = values[6] + else: + values = _unpack(data, ">6i", cursor, f"material parameter {index}") + type_offset = values[0] + value_offset = values[1] + cursor += block_size + + kind = _read_cstring(data, offset + type_offset) + value = _read_cstring(data, offset + value_offset) + parameters.append(MaterialParameter(kind=kind, value=value)) + if kind == "Name": + mesh_names.append(value) + elif kind == "diffuse": + diffuse_names.append(value) + + return parameters, mesh_names, diffuse_names + + +def _parse_mesh_layout( + data: bytes, offset: int, warnings: list[str], platform: str = "ps3" +) -> _MeshLayout: + info = _unpack(data, ">iiHHi", offset, "mesh layout header") + if platform == "xbx": + descriptor_count = info[2] + descriptor_size = 16 + stride_byte_after = True + else: + descriptor_count = info[3] + descriptor_size = 8 + stride_byte_after = False + if not 0 < descriptor_count <= 256: + raise PSGDataError(f"invalid vertex descriptor count {descriptor_count}") + + descriptors = [ + _slice(data, offset + 16 + index * descriptor_size, descriptor_size, + f"vertex descriptor {index}") + for index in range(descriptor_count) + ] + if stride_byte_after: + stride = _slice(data, offset + 16 + descriptor_count * descriptor_size, 1, + "vertex stride")[0] + if stride == 0: + stride = 32 + else: + stride = descriptors[-1][5] + if stride == 0: + stride = max((descriptor[5] for descriptor in descriptors), default=0) + if not 0 < stride <= 4096: + raise PSGDataError(f"invalid vertex stride {stride}") + + attributes: list[VertexAttribute] = [] + position: VertexAttribute | None = None + uv1: VertexAttribute | None = None + + for descriptor in descriptors: + attribute = _classify_descriptor(descriptor, platform) + if attribute is None: + attributes.append( + VertexAttribute( + semantic="unknown", + offset=int.from_bytes( + descriptor[2:4] if platform == "ps3" else descriptor[0:4], + "big", + ), + data_type="raw", + components=0, + descriptor=descriptor, + ) + ) + continue + + attributes.append(attribute) + if attribute.semantic == "position" and position is None: + position = attribute + elif attribute.semantic == "uv1" and uv1 is None: + uv1 = attribute + + if position is None: + raw_descriptors = ", ".join(item.hex() for item in descriptors) + raise PSGDataError(f"unsupported position descriptor(s): {raw_descriptors}") + + position = _validated_attribute(position, stride, warnings, "position") + if uv1 is not None: + try: + uv1 = _validated_attribute(uv1, stride, warnings, "UV") + except PSGDataError as exc: + warnings.append(f"Mesh layout at 0x{offset:X}: {exc}; UVs ignored") + uv1 = None + + return _MeshLayout( + stride=stride, + position=position, + uv1=uv1, + attributes=tuple(attributes), + ) + + +def _classify_descriptor(descriptor: bytes, platform: str = "ps3") -> VertexAttribute | None: + if platform == "xbx": + return _classify_descriptor_x360(descriptor) + prefix = descriptor[:2] + suffix = descriptor[6:8] + raw_offset = int.from_bytes(descriptor[2:4], "big") + + if prefix == b"\x02\x03" and suffix == b"\x00\x01": + return VertexAttribute("position", raw_offset, "float32", 3, descriptor) + if prefix in (b"\x03\x03", b"\x03\x04") and suffix == b"\x00\x01": + return VertexAttribute("position", raw_offset, "float16", 3, descriptor) + if descriptor == b"\x00\x1A\x23\xA6\x00\x00\x00\x01": + return VertexAttribute("position", 0, "uint16", 3, descriptor) + if prefix == b"\x01\x04" and suffix == b"\x00\x01": + return VertexAttribute("position", raw_offset, "int16", 3, descriptor) + + if descriptor == b"\x00\x2C\x23\xA5\x00\x05\x00\x06": + return VertexAttribute("uv1", raw_offset, "float32", 2, descriptor) + if prefix in (b"\x03\x04", b"\x03\x02") and suffix == b"\x08\x01": + return VertexAttribute("uv1", raw_offset, "float16", 2, descriptor) + if prefix == b"\x01\x02" and suffix == b"\x08\x01": + return VertexAttribute("uv1", raw_offset, "int16", 2, descriptor) + if descriptor == b"\x00\x2C\x20\x59\x00\x05\x00\x06": + return VertexAttribute("uv1", raw_offset, "uint16", 2, descriptor) + + if prefix == b"\x01\x02" and suffix == b"\x09\x01": + return VertexAttribute("uv2", raw_offset, "int16", 2, descriptor) + if descriptor == b"\x00\x2C\x23\x5F\x00\x05\x02\x08": + return VertexAttribute("uv3", raw_offset, "float16", 2, descriptor) + return None + + +def _classify_descriptor_x360(descriptor: bytes) -> VertexAttribute | None: + """Classify a 16-byte X360 vertex descriptor record. + + Layout: ``offset:i`` (bytes 0-3), ``type:8s`` (bytes 4-11), ``unknown:i`` + (bytes 12-15). Positions always live at offset 0 in the buffer (the + plugin binds them there); UVs use the descriptor's offset field. + """ + kind = descriptor[4:12] + offset = int.from_bytes(descriptor[0:4], "big") + + if kind == b"\x00\x2A\x23\xB9\x00\x00\x00\x01": + return VertexAttribute("position", 0, "float32", 3, descriptor) + if kind == b"\x00\x1A\x23\x60\x00\x00\x00\x01": + return VertexAttribute("position", 0, "float16", 3, descriptor) + if kind == b"\x00\x1A\x23\xA6\x00\x00\x00\x01": + return VertexAttribute("position", 0, "uint16", 3, descriptor) + if kind == b"\x00\x1A\x21\x5A\x00\x00\x00\x01": + return VertexAttribute("position", 0, "int16", 3, descriptor) + + if kind == b"\x00\x2C\x23\xA5\x00\x05\x00\x06": + return VertexAttribute("uv1", offset, "float32", 2, descriptor) + if kind in (b"\x00\x1A\x23\x60\x00\x05\x00\x06", + b"\x00\x2C\x23\x5F\x00\x05\x00\x06"): + return VertexAttribute("uv1", offset, "float16", 2, descriptor) + if kind == b"\x00\x2C\x21\x59\x00\x05\x00\x06": + return VertexAttribute("uv1", offset, "int16", 2, descriptor) + if kind == b"\x00\x2C\x20\x59\x00\x05\x00\x06": + return VertexAttribute("uv1", offset, "uint16", 2, descriptor) + + if kind == b"\x00\x2C\x21\x59\x00\x05\x01\x07": + return VertexAttribute("uv2", offset, "int16", 2, descriptor) + if kind == b"\x00\x2C\x23\x5F\x00\x05\x02\x08": + return VertexAttribute("uv3", offset, "float16", 2, descriptor) + return None + + +def _validated_attribute( + attribute: VertexAttribute, + stride: int, + warnings: list[str], + label: str, +) -> VertexAttribute: + dtype = _DTYPE_MAP[attribute.data_type] + required = dtype.itemsize * attribute.components + if 0 <= attribute.offset and attribute.offset + required <= stride: + return attribute + + if required <= stride: + warnings.append( + f"{label} descriptor offset 0x{attribute.offset:X} exceeds stride " + f"{stride}; using offset 0" + ) + return VertexAttribute( + semantic=attribute.semantic, + offset=0, + data_type=attribute.data_type, + components=attribute.components, + descriptor=attribute.descriptor, + ) + raise PSGDataError( + f"{label} attribute needs {required} bytes but vertex stride is {stride}" + ) + + +def _parse_vertices( + data: bytes, + resource: _BufferResource, + header_size: int, + layout: _MeshLayout, + warnings: list[str], + mesh_index: int, +) -> tuple[np.ndarray, np.ndarray | None]: + if resource.size <= 0: + raise PSGDataError(f"invalid vertex buffer size {resource.size}") + buffer_offset = resource.offset + header_size + _slice(data, buffer_offset, resource.size, "vertex buffer") + + vertex_count, remainder = divmod(resource.size, layout.stride) + if vertex_count <= 0: + raise PSGDataError("vertex buffer contains no complete vertices") + if remainder: + warnings.append( + f"Mesh {mesh_index}: vertex buffer has {remainder} trailing byte(s)" + ) + + vertices = _decode_interleaved( + data, buffer_offset, vertex_count, layout.stride, layout.position + ) + if not np.isfinite(vertices).all(): + invalid = int(np.size(vertices) - np.isfinite(vertices).sum()) + warnings.append( + f"Mesh {mesh_index}: replaced {invalid} non-finite position value(s) with zero" + ) + vertices = np.nan_to_num(vertices, copy=False) + + uvs = None + if layout.uv1 is not None: + uvs = _decode_interleaved( + data, buffer_offset, vertex_count, layout.stride, layout.uv1 + ) + if not np.isfinite(uvs).all(): + uvs = np.nan_to_num(uvs, copy=False) + + return vertices, uvs + + +def _parse_faces( + data: bytes, + resource: _BufferResource, + header_size: int, + vertex_count: int, + warnings: list[str], + mesh_index: int, + platform: str = "ps3", +) -> np.ndarray: + if resource.size < 0: + raise PSGDataError(f"invalid face buffer size {resource.size}") + + count_offset = resource.info_offset + (32 if platform == "xbx" else 8) + index_count = _read_i32(data, count_offset, "face index count") + if index_count < 0: + raise PSGDataError(f"invalid face index count {index_count}") + + buffer_offset = resource.offset + header_size + _slice(data, buffer_offset, resource.size, "face buffer") + available = resource.size // 2 + if index_count > available: + warnings.append( + f"Mesh {mesh_index}: header requests {index_count} indices but buffer " + f"contains {available}; truncating" + ) + index_count = available + if index_count % 3: + trimmed = index_count % 3 + warnings.append( + f"Mesh {mesh_index}: dropping {trimmed} index value(s) not forming a triangle" + ) + index_count -= trimmed + if index_count == 0: + return np.empty((0, 3), dtype=np.uint32) + + indices = np.frombuffer( + data, dtype=np.dtype(">u2"), count=index_count, offset=buffer_offset + ).astype(np.uint32, copy=True) + faces = indices.reshape(-1, 3) + valid = np.all(faces < vertex_count, axis=1) + invalid_count = int((~valid).sum()) + if invalid_count: + warnings.append( + f"Mesh {mesh_index}: dropped {invalid_count} triangle(s) with out-of-range indices" + ) + faces = faces[valid] + return np.ascontiguousarray(faces, dtype=np.uint32) + + +def _decode_interleaved( + data: bytes, + buffer_offset: int, + count: int, + stride: int, + attribute: VertexAttribute, +) -> np.ndarray: + dtype = _DTYPE_MAP[attribute.data_type] + required_end = ( + buffer_offset + + (count - 1) * stride + + attribute.offset + + attribute.components * dtype.itemsize + ) + if required_end > len(data): + raise PSGDataError( + f"{attribute.semantic} data ends at 0x{required_end:X}, beyond the file" + ) + + array = np.ndarray( + shape=(count, attribute.components), + dtype=dtype, + buffer=data, + offset=buffer_offset + attribute.offset, + strides=(stride, dtype.itemsize), + ) + values = array.astype(np.float32, copy=True) + # Noesis normalizes integer vertex attributes (SHORT -> /32768, + # USHORT -> /65535) the same way as position buffers. + if attribute.data_type == "int16": + values /= np.float32(32768.0) + elif attribute.data_type == "uint16": + values /= np.float32(65535.0) + return values + + +def _parse_bones( + data: bytes, + offset: int, + skeleton_index: int, + warnings: list[str], +) -> list[Bone]: + header = _unpack(data, ">12i4H2i", offset, "bone header") + bone_count = header[14] + names_relative_offset = header[11] + if bone_count > 20_000: + raise PSGDataError(f"invalid bone count {bone_count}") + + matrix_offset = offset + struct.calcsize(">12i4H2i") + matrices_end = matrix_offset + bone_count * 64 + if matrices_end > len(data): + raise PSGDataError("bone matrices extend beyond the file") + + matrices: list[np.ndarray] = [] + for index in range(bone_count): + values = _unpack(data, ">16f", matrix_offset + index * 64, "bone matrix") + matrices.append(np.asarray(values, dtype=np.float32).reshape(4, 4)) + + names_offset = offset + names_relative_offset + if not 0 <= names_offset < len(data): + warnings.append( + f"Skeleton {skeleton_index}: bone-name table is outside the file; using generated names" + ) + names = [f"bone_{index}" for index in range(bone_count)] + else: + names = [] + cursor = names_offset + for index in range(bone_count): + try: + name, cursor = _read_cstring_with_end(data, cursor) + except PSGDataError: + name = f"bone_{index}" + names.append(name or f"bone_{index}") + + return [ + Bone(name=name, matrix=matrix, skeleton_index=skeleton_index) + for name, matrix in zip(names, matrices) + ] + + +def _name_for_mesh(names: list[str], index: int, mesh_count: int) -> str: + if len(names) == mesh_count and names[index]: + return names[index] + if index < len(names) and names[index]: + return names[index] + return f"mesh_{index}" + + +def _material_for_mesh( + diffuse_names: list[str], index: int, mesh_count: int +) -> str | None: + if len(diffuse_names) == mesh_count: + return diffuse_names[index] or None + if index < len(diffuse_names): + return diffuse_names[index] or None + return None + + +def _deduplicate(values: Iterable[int]) -> list[int]: + return list(dict.fromkeys(values)) + + +def _read_i32(data: bytes, offset: int, label: str) -> int: + return _unpack(data, ">i", offset, label)[0] + + +def _unpack(data: bytes, fmt: str, offset: int, label: str) -> tuple: + if offset < 0: + raise PSGDataError(f"{label} has a negative offset {offset}") + size = struct.calcsize(fmt) + if offset + size > len(data): + raise PSGDataError( + f"{label} at 0x{offset:X} needs {size} bytes, file has {len(data)}" + ) + return struct.unpack_from(fmt, data, offset) + + +def _slice(data: bytes, offset: int, size: int, label: str) -> bytes: + if offset < 0 or size < 0 or offset + size > len(data): + raise PSGDataError( + f"{label} range 0x{offset:X}..0x{offset + size:X} is outside the file" + ) + return data[offset : offset + size] + + +def _read_cstring(data: bytes, offset: int) -> str: + value, _ = _read_cstring_with_end(data, offset) + return value + + +def _compute_vertex_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray | None: + if len(faces) == 0: + return None + normals = np.zeros_like(vertices) + face_positions = vertices[faces] + face_normals = np.cross( + face_positions[:, 1] - face_positions[:, 0], + face_positions[:, 2] - face_positions[:, 0], + ) + lengths = np.linalg.norm(face_normals, axis=1) + valid = lengths > 1e-12 + face_normals[valid] /= lengths[valid, None] + for i in range(3): + np.add.at(normals, faces[:, i], face_normals) + vertex_lengths = np.linalg.norm(normals, axis=1) + valid_vertices = vertex_lengths > 1e-12 + normals[valid_vertices] /= vertex_lengths[valid_vertices, None] + return normals + + +def _read_cstring_with_end(data: bytes, offset: int) -> tuple[str, int]: + if not 0 <= offset < len(data): + raise PSGDataError(f"string offset 0x{offset:X} is outside the file") + end = data.find(b"\x00", offset, min(len(data), offset + 65_536)) + if end < 0: + raise PSGDataError(f"unterminated string at 0x{offset:X}") + return data[offset:end].decode("utf-8", errors="replace"), end + 1 diff --git a/tools/vendor/utt/rx2_parser.py b/tools/vendor/utt/rx2_parser.py new file mode 100644 index 00000000..c364071d --- /dev/null +++ b/tools/vendor/utt/rx2_parser.py @@ -0,0 +1,982 @@ +"""Standalone parser for EA Skate 1/2/3 RenderWare4 .rx2 files (Xbox 360). + +Pure-Python reimplementation of the Xbox 360 RX2 reading logic from the +Noesis plugin mdl_EA_SKATE_4_7_0.py (Beedy / GHFear / tuukkas). It does not +require Noesis or the plugin. + +Scope: the .rx2 container (Xbox 360). The PS3 .psg container is not covered. + +What it can do: + * read the RX2 header (file count, file table, data base, container type) + * walk the 24-byte file table (TOC) records + * find and decode texture entries (DXT1 / DXT3 / DXT5 / ATI2 / + A8R8G8B8 / B5G6R5 / A8) into plain RGBA8888 byte buffers, including the + Xbox 360 GPU tiled-memory layout (XGAddress2DTiled swizzle) + +Typical use: + + import rx2_parser + rx2 = rx2_parser.parse_rx2("some_file.rx2") + for tex in rx2.textures: + print(tex) # e.g. "tex0 | 512x512 | DXT1" + tex.save_png("tex%d.png" % tex.index) + +Run `python rx2_parser.py file.rx2 --toc --out-dir out` for a CLI dump. +See rx2parserexample.txt for the file format notes. +""" + +import struct +from pathlib import Path + +MAGIC_X360 = b"\x89RW4xb2" + +TYPE_SIM = 0x00000001 +TYPE_MESH = 0x00000004 +TYPE_TEXTURE = 0x00001000 +TYPE_NHL_LEGACY_MESH = 0x00000800 + +TYPE_NAMES = { + TYPE_SIM: "Sim / collision", + TYPE_MESH: "3D model", + TYPE_TEXTURE: "Texture", + TYPE_NHL_LEGACY_MESH: "3D model (NHL legacy)", +} + +TOC_TEXTURE_SKATE = 0x000200E8 +TOC_TEXTURE_NHL = 0x00020003 +TOC_MESH_INFO = 0x000200E9 +TOC_VERTICES = 0x000200EA +TOC_FACES = 0x000200EB +TOC_MATERIALS = 0x00EB0005 +TOC_BONES = 0x00EB0001 +TOC_SIM = 0x00EB000B + +TOC_TYPE_NAMES = { + TOC_TEXTURE_SKATE: "texture (Skate)", + TOC_TEXTURE_NHL: "texture (NHL)", + TOC_MESH_INFO: "mesh info", + TOC_VERTICES: "vertices", + TOC_FACES: "faces", + TOC_MATERIALS: "materials", + TOC_BONES: "bones", + TOC_SIM: "sim", +} + +FMT_DXT1 = 0x52 +FMT_DXT3 = 0x53 +FMT_DXT5 = 0x54 +FMT_ATI2 = 0x71 +FMT_DXT1_NORMAL = 0x7C +FMT_A8R8G8B8 = 0x86 +FMT_B5G6R5 = 0x44 +FMT_A8 = 0x02 + +FMT_NAMES = { + FMT_DXT1: "DXT1", + FMT_DXT3: "DXT3", + FMT_DXT5: "DXT5", + FMT_ATI2: "ATI2", + FMT_DXT1_NORMAL: "DXT1 normal", + FMT_A8R8G8B8: "A8R8G8B8", + FMT_B5G6R5: "B5G6R5", + FMT_A8: "A8", +} + + +class RX2ParseError(Exception): + pass + + +class TOCEntry(object): + """One 24-byte record from the RX2 file table. + + Records come in pairs: the record directly before a type record carries + the payload pointer/size for that type. Concretely, when record N's type + matches: + data offset (relative to the header-size value at 0x44) = record N-1 f0 + data size = record N-1 f2 + info block offset (absolute) = record N f0 + """ + + __slots__ = ("index", "f0", "f1", "f2", "f3", "f4", "type_id", + "data_offset", "buffer_size", "info_offset", "is_texture") + + def __init__(self, index, f0, f1, f2, f3, f4, type_id): + self.index = index + self.f0 = f0 + self.f1 = f1 + self.f2 = f2 + self.f3 = f3 + self.f4 = f4 + self.type_id = type_id + self.data_offset = None + self.buffer_size = None + self.info_offset = f0 + self.is_texture = type_id in (TOC_TEXTURE_SKATE, TOC_TEXTURE_NHL) + + @property + def type_name(self): + return TOC_TYPE_NAMES.get(self.type_id, "unknown") + + def __repr__(self): + return ("TOCEntry(%d) type=0x%08X (%s) data_off=%s size=%s info_off=%s" + % (self.index, self.type_id, self.type_name, + "0x%X" % self.data_offset if self.data_offset is not None else "-", + self.buffer_size if self.buffer_size is not None else "-", + "0x%X" % self.info_offset if self.info_offset is not None else "-")) + + +class Texture(object): + """A decoded texture: width x height RGBA8888 pixels in `rgba`.""" + + __slots__ = ("index", "width", "height", "fmt_id", "rgba", + "data_offset", "buffer_size") + + def __init__(self, index, width, height, fmt_id, rgba, data_offset, buffer_size): + self.index = index + self.width = width + self.height = height + self.fmt_id = fmt_id + self.rgba = rgba + self.data_offset = data_offset + self.buffer_size = buffer_size + + @property + def name(self): + return "tex%d" % self.index + + @property + def fmt_name(self): + return FMT_NAMES.get(self.fmt_id, "0x%02X" % self.fmt_id) + + @property + def size(self): + return (self.width, self.height) + + def to_pil(self): + """Return a Pillow RGBA image (requires Pillow).""" + from PIL import Image + return Image.frombytes("RGBA", self.size, self.rgba) + + def save_png(self, path): + """Save the texture as a PNG (requires Pillow).""" + self.to_pil().save(path, "PNG") + + def __str__(self): + return "%s | %dx%d | %s" % (self.name, self.width, self.height, self.fmt_name) + + +class RX2File(object): + """A parsed RX2 container: header fields, TOC entries, decoded textures.""" + + def __init__(self, data, path=None): + if isinstance(data, (bytes, bytearray)): + self.data = bytes(data) + else: + self.data = Path(data).read_bytes() + path = str(data) + self.path = path + self.file_count = 0 + self.file_table_offset = 0 + self.data_base = 0 + self.type_id = 0 + self.entries = [] + self.textures = [] + self.warnings = [] + + @property + def magic(self): + return self.data[0:7] + + @property + def type_name(self): + return TYPE_NAMES.get(self.type_id, "unknown (0x%08X)" % self.type_id) + + def parse(self): + """Read the header and the file table. Idempotent.""" + data = self.data + if len(data) < 0x5C: + raise RX2ParseError("file is too small to be an RX2") + if data[0:7] != MAGIC_X360: + raise RX2ParseError("not an Xbox 360 RX2 (bad magic 0x%s)" % data[0:7].hex()) + + self.file_count = int.from_bytes(data[0x20:0x24], "big") + self.file_table_offset = int.from_bytes(data[0x30:0x34], "big") + self.data_base = int.from_bytes(data[0x44:0x48], "big") + self.type_id = int.from_bytes(data[0x58:0x5C], "big") + + if self.file_count > (1 << 20): + raise RX2ParseError("implausible file count: %d" % self.file_count) + if self.file_table_offset + self.file_count * 24 > len(data): + raise RX2ParseError("file table runs past the end of the file") + + self.entries = [] + prev_f0 = None + prev_f2 = None + for i in range(self.file_count): + p = self.file_table_offset + i * 24 + f0, f1, f2, f3, f4 = struct.unpack_from(">iiiii", data, p) + type_id = int.from_bytes(data[p + 20:p + 24], "big") + entry = TOCEntry(i, f0, f1, f2, f3, f4, type_id) + if i > 0: + entry.data_offset = prev_f0 + entry.buffer_size = prev_f2 + self.entries.append(entry) + prev_f0, prev_f2 = f0, f2 + return self + + def decode_textures(self): + """Decode every texture entry found in the TOC. Idempotent.""" + self.textures = [] + for entry in self.entries: + if entry.is_texture: + tex = self._decode_texture(entry) + if tex is not None: + self.textures.append(tex) + return self.textures + + def _decode_texture(self, entry): + data = self.data + hdr_off = entry.info_offset + if hdr_off is None or hdr_off + 40 > len(data): + self.warnings.append("texture entry %d: bad info offset" % entry.index) + return None + hdr = data[hdr_off:hdr_off + 40] + fmt = hdr[35] + size = (hdr[36] << 24) | (hdr[37] << 16) | (hdr[38] << 8) | hdr[39] + width = (size & 0x1FFF) + 1 + height = ((size >> 13) & 0x1FFF) + 1 + dxt5_variant = hdr[28] + base = self.data_base + entry.data_offset + size = entry.buffer_size or 0 + if width <= 1 and height <= 8: + genrx2 = self._genrx2_geometry(hdr) + if genrx2 is None: + self.warnings.append( + "texture entry %d: bad dimensions %dx%d" % (entry.index, width, height) + ) + return None + width, height, fmt, dxt5_variant = genrx2 + raw = data[base:base + size] + try: + rgba = _decode_texture_data(raw, width, height, fmt, dxt5_variant) + except Exception as exc: + self.warnings.append("texture entry %d: decode failed: %s" % (entry.index, exc)) + return None + return Texture(entry.index, width, height, fmt, rgba, base, size) + + @staticmethod + def _genrx2_geometry(hdr): + """Recover dimensions for RX2 files written by the genrx2 converter. + + The tool writes a header variant that omits the D3D-style fields the + game exporter uses. Its two 32-bit fields encode, empirically: + + [28:32] = 64 * (mip_count - 1) -> max_dim = 1 << (value / 64) + [32:36] = 0xA00 + max(0x4000, max_dim^2) -> padding + tiled size + + genrx2 always emits byte-swapped DXT5 with the linear (until-360-tiled) + layout, which the parser selects with variant 84 (the game's plain + DXT5 value). The aspect ratio is not stored; square textures decode + exactly, non-square ones decode upscaled to the square extent. + """ + mip_field = int.from_bytes(hdr[28:32], "big") + size_field = int.from_bytes(hdr[32:36], "big") + if mip_field <= 0 or mip_field % 64 != 0: + return None + max_dim = 1 << (mip_field // 64) + if max_dim > 8192: + return None + expected = 0xA00 + max(0x4000, max_dim * max_dim) + if size_field != expected: + return None + return max_dim, max_dim, FMT_DXT5, 84 + + def __str__(self): + return ("RX2File(%s)\n" + " type: %s\n" + " file count: %d\n" + " file table: 0x%X\n" + " data base: 0x%X\n" + " TOC entries: %d\n" + " textures: %d" + % (self.path, self.type_name, self.file_count, + self.file_table_offset, self.data_base, + len(self.entries), len(self.textures))) + + +def parse_rx2(source): + """Parse an RX2 (path or raw bytes) and decode every texture in it.""" + rx2 = RX2File(source) + rx2.parse() + rx2.decode_textures() + return rx2 + + +def patch_texture_entry(data, width, height): + """Repair the texture entry of a converted RX2 so third-party readers + (the Noesis plugin) see the real format and dimensions. + + genrx2 writes the payload and the TOC record, but leaves the game-layout + fields of the 40-byte texture info block at zero (format byte at +35, + height byte at +37 and the 16-bit width at +38), so external tools report + a 1x8 image with an unhandled format. This restores those fields using + the encoding the Noesis plugin reads, and trims the advertised buffer + size (previous TOC record field 2) to the actual payload length so + readers never read past the end of the file. + + Returns a new byte string; the input is not modified. + """ + data = bytearray(data) + rx2 = RX2File(bytes(data)) + rx2.parse() + entry = next((e for e in rx2.entries if e.is_texture), None) + if entry is None: + raise RX2ParseError("no texture entry to patch") + if entry.info_offset is None or entry.info_offset + 40 > len(data): + raise RX2ParseError("texture info block runs past the end of the file") + if not 8 <= height <= 2048 or not 1 <= width <= 8192: + raise RX2ParseError("unsupported texture size %dx%d" % (width, height)) + p = entry.info_offset + data[p + 35] = FMT_DXT5 + data[p + 36:p + 40] = struct.pack(">I", ((height - 1) << 13) | (width - 1)) + if entry.buffer_size is not None: + payload = len(data) - rx2.data_base + if payload >= 0: + f2_pos = rx2.file_table_offset + (entry.index - 1) * 24 + 8 + data[f2_pos:f2_pos + 4] = struct.pack(">I", payload) + return bytes(data) + + +# --------------------------------------------------------------------------- +# Byte order / tiling helpers (mirror the Noesis plugin's behaviour) +# --------------------------------------------------------------------------- + +def _swap16(data): + """Byte-swap every 16-bit word (X360 DXT/RGB565 storage order).""" + b = bytearray(data) + for i in range(0, len(b) - 1, 2): + b[i], b[i + 1] = b[i + 1], b[i] + return bytes(b) + + +def _x360_tiled_x(offset, width_units, texel_pitch): + """XGAddress2DTiledX: swizzled column of a tiled 2D surface (X360 GPU).""" + aligned_width = (width_units + 31) & ~31 + log_bpp = (texel_pitch >> 2) + ((texel_pitch >> 1) >> (texel_pitch >> 2)) + off_b = offset << log_bpp + off_t = ((off_b & ~4095) >> 3) + ((off_b & 1792) >> 2) + (off_b & 63) + off_m = off_t >> (7 + log_bpp) + macro_x = (off_m % (aligned_width >> 5)) << 2 + tile = (((off_t >> (5 + log_bpp)) & 2) + (off_b >> 6)) & 3 + macro = (macro_x + tile) << 3 + micro = ((((off_t >> 1) & ~15) + (off_t & 15)) & ((texel_pitch << 3) - 1)) >> log_bpp + return macro + micro + + +def _x360_tiled_y(offset, width_units, texel_pitch): + """XGAddress2DTiledY: swizzled row of a tiled 2D surface (X360 GPU).""" + aligned_width = (width_units + 31) & ~31 + log_bpp = (texel_pitch >> 2) + ((texel_pitch >> 1) >> (texel_pitch >> 2)) + off_b = offset << log_bpp + off_t = ((off_b & ~4095) >> 3) + ((off_b & 1792) >> 2) + (off_b & 63) + off_m = off_t >> (7 + log_bpp) + macro_y = (off_m // (aligned_width >> 5)) << 2 + tile = ((off_t >> (6 + log_bpp)) & 1) + ((off_b & 2048) >> 10) + macro = (macro_y + tile) << 3 + micro = ((((off_t & (((texel_pitch << 6) - 1) & ~31)) + ((off_t & 15) << 1)) >> (3 + log_bpp)) & ~1) + return macro + micro + ((off_t & 16) >> 4) + + +def _untile360(src, width_units, texel_pitch): + """Reverse the X360 GPU tiled-memory layout (XGAddress2DTiled swizzle). + + ``width_units`` is the surface width in units (4x4-pixel blocks for DXT + formats, texels for raw formats) and ``texel_pitch`` is the size of one + unit in bytes (8 or 16 for DXT, bytes-per-pixel for raw). + """ + dst = bytearray(len(src)) + row_bytes = width_units * texel_pitch + units = len(src) // texel_pitch + for j in range(units // width_units): + for i in range(width_units): + x = _x360_tiled_x(j * width_units + i, width_units, texel_pitch) + y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch) + dst_idx = (y * width_units + x) * texel_pitch + src_idx = (j * width_units + i) * texel_pitch + if dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src): + dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch] + return bytes(dst) + + +def _untile360_dxt(src, tex_w, tex_h, blk_size): + """Reverse the X360 GPU tiling for DXT-style formats (units = 4x4 blocks).""" + return _untile360(src, (tex_w + 3) >> 2, blk_size) + + +def _untile360_raw(src, tex_w, tex_h, bpp): + """Reverse the X360 GPU tiling for raw pixel formats (units = texels).""" + return _untile360(src, tex_w, bpp) + + +# --------------------------------------------------------------------------- +# DXT / raw decoders (all produce RGBA8888 row-major bytes) +# --------------------------------------------------------------------------- + +def _rgb565(value): + """Expand a 16-bit 565 value to 888 (v*255/denom, truncated). + + Verified against Noesis's imageDecodeDXT output: truncation, not + D3DX-style rounding (rounding adds a constant +1 offset to ~1 in 10 + pixels vs the game's decoder).""" + return ((((value >> 11) & 0x1F) * 255) // 31, + (((value >> 5) & 0x3F) * 255) // 63, + (((value & 0x1F) * 255) // 31)) + + +def _rgb565_interp(a5, b5, a6, b6, a1, b1): + """The two interpolated palette colours, computed from the 888 values: + (2a+b+1)/3 resp. (a+2b+1)/3, integer rounding (matches Noesis).""" + r0 = (a5 * 255) // 31 + r1 = (b5 * 255) // 31 + g0 = (a6 * 255) // 63 + g1 = (b6 * 255) // 63 + b0 = (a1 * 255) // 31 + b1v = (b1 * 255) // 31 + return ((2 * r0 + r1 + 1) // 3, (2 * g0 + g1 + 1) // 3, (2 * b0 + b1v + 1) // 3), \ + ((r0 + 2 * r1 + 1) // 3, (g0 + 2 * g1 + 1) // 3, (b0 + 2 * b1v + 1) // 3) + + +def _dxt1_block(blk, transparent): + c0, c1 = struct.unpack_from(">HH", blk, 0) + r0, g0, b0 = _rgb565(c0) + r1, g1, b1 = _rgb565(c1) + if c0 > c1: + c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F, + (c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F, + c0 & 0x1F, c1 & 0x1F) + palette = [(r0, g0, b0), (r1, g1, b1), c2, c3] + alpha3 = 255 + else: + palette = [(r0, g0, b0), (r1, g1, b1), + ((r0 + r1) // 2, (g0 + g1) // 2, (b0 + b1) // 2), + (0, 0, 0)] + alpha3 = 0 if transparent else 255 + out = bytearray(64) + for y in range(4): + idx_byte = blk[4 + (y ^ 1)] + for x in range(4): + i = (idx_byte >> (x * 2)) & 3 + o = (y * 4 + x) * 4 + r, g, b = palette[i] + out[o] = r + out[o + 1] = g + out[o + 2] = b + out[o + 3] = alpha3 if i == 3 else 255 + return bytes(out) + + +def _dxt3_block(blk): + color = _dxt1_block(blk[8:16], transparent=False) + out = bytearray(64) + for p in range(16): + # alpha nibbles are word-swapped like the DXT5 alpha bits + a = (blk[(p // 2) ^ 1] >> (4 * (p & 1))) & 0xF + o = p * 4 + out[o] = color[o] + out[o + 1] = color[o + 1] + out[o + 2] = color[o + 2] + out[o + 3] = a * 17 + return bytes(out) + + +def _alpha_table(a0, a1): + if a0 > a1: + return [a0, a1] + [((6 - i) * a0 + (i + 1) * a1) // 7 for i in range(6)] + return [a0, a1, + (4 * a0 + a1) // 5, (3 * a0 + 2 * a1) // 5, + (2 * a0 + 3 * a1) // 5, (a0 + 4 * a1) // 5, 0, 255] + + +def _dxt5_block(blk): + # the alpha endpoint bytes and the 48 index bits are 16-bit-word-swapped + # (same swapEndianArray(data, 2) the Noesis plugin applies before decode) + table = _alpha_table(blk[1], blk[0]) + bits = int.from_bytes(_swap16(blk[2:8]), "little") + color = _dxt1_block(blk[8:16], transparent=False) + out = bytearray(64) + for p in range(16): + o = p * 4 + out[o] = color[o] + out[o + 1] = color[o + 1] + out[o + 2] = color[o + 2] + out[o + 3] = table[(bits >> (3 * p)) & 7] + return bytes(out) + + +def _ati2_channel(blk, off): + table = _alpha_table(blk[off + 1], blk[off]) + bits = int.from_bytes(_swap16(blk[off + 2:off + 8]), "little") + return [table[(bits >> (3 * p)) & 7] for p in range(16)] + + +def _ati2_block(blk): + r = _ati2_channel(blk, 0) + g = _ati2_channel(blk, 8) + out = bytearray(64) + for p in range(16): + o = p * 4 + out[o] = r[p] + out[o + 1] = g[p] + out[o + 2] = 0 + out[o + 3] = 255 + return bytes(out) + + +def _decode_blocks(data, width, height, block_size, block_fn): + bw = (width + 3) >> 2 + bh = (height + 3) >> 2 + out = bytearray(width * height * 4) + for by in range(bh): + for bx in range(bw): + off = (by * bw + bx) * block_size + blk = data[off:off + block_size] + if len(blk) < block_size: + continue + px = block_fn(blk) + x0 = bx * 4 + y0 = by * 4 + for yy in range(4): + row = y0 + yy + if row >= height: + break + base = (row * width + x0) * 4 + if x0 + 4 <= width: + out[base:base + 16] = px[yy * 16:(yy + 1) * 16] + else: + for xx in range(width - x0): + s = (yy * 4 + xx) * 4 + d = base + xx * 4 + out[d:d + 4] = px[s:s + 4] + return bytes(out) + + +def decode_dxt1(data, width, height): + return _decode_blocks(_untile360_dxt(data, width, height, 8), + width, height, 8, lambda b: _dxt1_block(b, True)) + + +def decode_dxt1_normal(data, width, height): + rgba = decode_dxt1(data, width, height) + out = bytearray(rgba) + for i in range(0, len(rgba), 4): + r = out[i] / 255.0 + g = out[i + 1] / 255.0 + z2 = 1.0 - r * r - g * g + out[i + 2] = int(round((z2 ** 0.5 if z2 > 0 else 0.0) * 255)) + out[i + 3] = 255 + return bytes(out) + + +def decode_dxt3(data, width, height): + return _decode_blocks(_untile360_dxt(data, width, height, 16), + width, height, 16, _dxt3_block) + + +def decode_dxt5(data, width, height, tiled=True): + blocks = _untile360_dxt(data, width, height, 16) if tiled else data + return _decode_blocks(blocks, width, height, 16, _dxt5_block) + + +def decode_ati2(data, width, height): + return _decode_blocks(_untile360_dxt(data, width, height, 16), + width, height, 16, _ati2_block) + + +def _decode_raw_a8r8g8b8(data, width, height): + out = bytearray(width * height * 4) + n = min(len(data), width * height * 4) // 4 * 4 + for i in range(0, n, 4): + o = i + out[o] = data[i + 1] + out[o + 1] = data[i + 2] + out[o + 2] = data[i + 3] + out[o + 3] = data[i] + return bytes(out) + + +def _decode_raw_b5g6r5(data, width, height): + out = bytearray(width * height * 4) + n = min(len(data), width * height * 2) // 2 * 2 + for i in range(0, n, 2): + v = (data[i] << 8) | data[i + 1] + o = (i // 2) * 4 + out[o] = ((v >> 11) & 0x1F) * 255 // 31 + out[o + 1] = ((v >> 5) & 0x3F) * 255 // 63 + out[o + 2] = (v & 0x1F) * 255 // 31 + out[o + 3] = 255 + return bytes(out) + + +def _decode_raw_a8(data, width, height): + out = bytearray(width * height * 4) + n = min(len(data), width * height) + for i in range(n): + o = i * 4 + v = data[i] + out[o] = v + out[o + 1] = v + out[o + 2] = v + out[o + 3] = v + return bytes(out) + + +def _rgb565_pack(r, g, b): + """Pack 8-bit RGB into a 565 word (top of the range).""" + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) + + +def _encode_dxt5_block(px): + """Encode 16 RGBA pixels into one 16-byte X360 DXT5 block. + + The Xbox 360 stores DXT blocks with the alpha part first and every + 16-bit word byte-swapped; ``_dxt5_block`` / ``_dxt1_block`` decode the + same layout, so encode -> decode round-trips exactly. + + ``px`` is a list of 16 ``(r, g, b, a)`` tuples in row-major order. + """ + alphas = [p[3] for p in px] + a_min = min(alphas) + a_max = max(alphas) + if a_max > a_min: + a0 = a_max + a1 = a_min + table = [a0, a1] + [((6 - i) * a0 + (i + 1) * a1) // 7 for i in range(6)] + bits = 0 + for p in range(16): + value = alphas[p] + best = 0 + best_d = abs(table[0] - value) + for i in range(1, 8): + d = abs(table[i] - value) + if d < best_d: + best_d = d + best = i + bits |= best << (3 * p) + aidx = _swap16(bits.to_bytes(6, "little")) + else: + a0 = a1 = alphas[0] + aidx = b"\x00" * 6 + + lo = (min(p[0] for p in px), min(p[1] for p in px), min(p[2] for p in px)) + hi = (max(p[0] for p in px), max(p[1] for p in px), max(p[2] for p in px)) + c0 = _rgb565_pack(*hi) + c1 = _rgb565_pack(*lo) + r0, g0, b0 = _rgb565(c0) + r1, g1, b1 = _rgb565(c1) + c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F, + (c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F, + c0 & 0x1F, c1 & 0x1F) + palette = [(r0, g0, b0), (r1, g1, b1), c2, c3] + + blk = bytearray(16) + blk[0] = a1 + blk[1] = a0 + blk[2:8] = aidx + blk[8:10] = struct.pack(">H", c0) + blk[10:12] = struct.pack(">H", c1) + for y in range(4): + byte = 0 + for x in range(4): + r, g, b = px[y * 4 + x][:3] + best = 0 + best_d = (palette[0][0] - r) ** 2 + (palette[0][1] - g) ** 2 + (palette[0][2] - b) ** 2 + for i in range(1, 4): + pr, pg, pb = palette[i] + d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2 + if d < best_d: + best_d = d + best = i + byte |= best << (x * 2) + blk[12 + (y ^ 1)] = byte + return bytes(blk) + + +def _tile360(src, width_units, texel_pitch): + """Apply the X360 GPU tiled-memory layout (inverse of ``_untile360``).""" + dst = bytearray(len(src)) + units = len(src) // texel_pitch + for j in range(units // width_units): + for i in range(width_units): + x = _x360_tiled_x(j * width_units + i, width_units, texel_pitch) + y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch) + src_idx = (y * width_units + x) * texel_pitch + dst_idx = (j * width_units + i) * texel_pitch + if src_idx + texel_pitch <= len(src) and dst_idx + texel_pitch <= len(dst): + dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch] + return bytes(dst) + + +def _encode_dxt5_np(rgba, width, height): + """numpy-accelerated ``encode_dxt5`` (vectorised block encoder).""" + import numpy as np + + arr = np.frombuffer(rgba, dtype=np.uint8).reshape(height, width, 4) + bh = (height + 3) >> 2 + bw = (width + 3) >> 2 + if height % 4 or width % 4: + pad = np.zeros((bh * 4, bw * 4, 4), dtype=np.uint8) + pad[:height, :width] = arr + arr = pad + blk = arr.reshape(bh, 4, bw, 4, 4).transpose(0, 2, 1, 3, 4) + blk = blk.reshape(bh * bw, 16, 4) + + alpha = blk[:, :, 3].astype(np.int16) + a_max = alpha.max(axis=1) + a_min = alpha.min(axis=1) + same = a_max == a_min + a0 = np.where(same, alpha[:, 0], a_max) + a1 = np.where(same, alpha[:, 0], a_min) + idx6 = np.arange(6, dtype=np.int16) + t2 = ((6 - idx6) * a_max[:, None] + (idx6 + 1) * a_min[:, None]) // 7 + table = np.concatenate([a_max[:, None], a_min[:, None], t2], axis=1) + best = np.argmin(np.abs(table[:, None, :] - alpha[:, :, None]), axis=2) + bits = (best.astype(np.int64) << np.arange(16, dtype=np.int64)[None, :] * 3).sum(axis=1) + aidx_le = ((bits[:, None] >> (8 * np.arange(6, dtype=np.int64))[None, :]) & 0xFF).astype(np.uint8) + aidx = aidx_le[:, [1, 0, 3, 2, 5, 4]] + + rgb = blk[:, :, :3].astype(np.int32) + hi = rgb.max(axis=1) + lo = rgb.min(axis=1) + c0 = ((hi[:, 0] >> 3) << 11) | ((hi[:, 1] >> 2) << 5) | (hi[:, 2] >> 3) + c1 = ((lo[:, 0] >> 3) << 11) | ((lo[:, 1] >> 2) << 5) | (lo[:, 2] >> 3) + r0 = ((c0 >> 11) & 0x1F) * 255 // 31 + g0 = ((c0 >> 5) & 0x3F) * 255 // 63 + b0 = (c0 & 0x1F) * 255 // 31 + r1 = ((c1 >> 11) & 0x1F) * 255 // 31 + g1 = ((c1 >> 5) & 0x3F) * 255 // 63 + b1 = (c1 & 0x1F) * 255 // 31 + pal0 = np.stack([r0, g0, b0], axis=1) + pal1 = np.stack([r1, g1, b1], axis=1) + pal2 = (2 * pal0 + pal1 + 1) // 3 + pal3 = (pal0 + 2 * pal1 + 1) // 3 + palette = np.stack([pal0, pal1, pal2, pal3], axis=1) + dist = ((palette[:, None, :, :] - rgb[:, :, None, :]) ** 2).sum(axis=-1) + cidx = np.argmin(dist, axis=2).reshape(-1, 4, 4) + row_bits = (cidx << np.array([0, 2, 4, 6], dtype=np.uint8)[None, None, :]).sum(axis=2) + row_bytes = row_bits[:, [1, 0, 3, 2]].astype(np.uint8) + + out = np.zeros((bh * bw, 16), dtype=np.uint8) + out[:, 0] = a1.astype(np.uint8) + out[:, 1] = a0.astype(np.uint8) + out[:, 2:8] = aidx + out[:, 8] = ((c0 >> 8) & 0xFF).astype(np.uint8) + out[:, 9] = (c0 & 0xFF).astype(np.uint8) + out[:, 10] = ((c1 >> 8) & 0xFF).astype(np.uint8) + out[:, 11] = (c1 & 0xFF).astype(np.uint8) + out[:, 12:16] = row_bytes + + units = bh * bw + k = np.arange(units, dtype=np.int64) + pitch = 16 + log_bpp = (pitch >> 2) + ((pitch >> 1) >> (pitch >> 2)) + aligned_width = (bw + 31) & ~31 + off_b = k << log_bpp + off_t = ((off_b & ~4095) >> 3) + ((off_b & 1792) >> 2) + (off_b & 63) + off_m = off_t >> (7 + log_bpp) + macro_x = (off_m % (aligned_width >> 5)) << 2 + tile_x = (((off_t >> (5 + log_bpp)) & 2) + (off_b >> 6)) & 3 + micro_x = ((((off_t >> 1) & ~15) + (off_t & 15)) & ((pitch << 3) - 1)) >> log_bpp + xs = ((macro_x + tile_x) << 3) + micro_x + macro_y = (off_m // (aligned_width >> 5)) << 2 + tile_y = ((off_t >> (6 + log_bpp)) & 1) + ((off_b & 2048) >> 10) + micro_y = ((((off_t & (((pitch << 6) - 1) & ~31)) + ((off_t & 15) << 1)) >> (3 + log_bpp)) & ~1) + ys = ((macro_y + tile_y) << 3) + micro_y + ((off_t & 16) >> 4) + perm = ys * bw + xs + valid = perm < units + tiled = np.zeros((units, 16), dtype=np.uint8) + tiled[valid] = out[perm[valid]] + return tiled.tobytes() + + +def encode_dxt5(rgba, width, height): + """Encode an RGBA8888 byte buffer to a tiled DXT5 mip (X360 layout). + + ``rgba`` must be exactly ``width * height * 4`` bytes, row-major. + Returns the tiled DXT5 byte buffer, ``bw * bh * 16`` bytes where + ``bw = (width + 3) >> 2`` etc. + """ + try: + return _encode_dxt5_np(rgba, width, height) + except ImportError: + pass + + bw = (width + 3) >> 2 + bh = (height + 3) >> 2 + linear = bytearray(bw * bh * 16) + for by in range(bh): + for bx in range(bw): + px = [] + for yy in range(4): + y = by * 4 + yy + row = y * width * 4 if y < height else -1 + for xx in range(4): + x = bx * 4 + xx + if row >= 0 and x < width: + o = row + x * 4 + px.append((rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3])) + else: + px.append((0, 0, 0, 0)) + blk = _encode_dxt5_block(px) + off = (by * bw + bx) * 16 + linear[off:off + 16] = blk + return _tile360(bytes(linear), bw, 16) + + +def _box_downsample(rgba, w, h, w2, h2): + """Average 2x2 pixel blocks into a smaller RGBA buffer.""" + try: + import numpy as _np + arr = _np.frombuffer(rgba, dtype=_np.uint8).reshape(h, w, 4) + if h & 1: + arr = _np.concatenate([arr, arr[-1:]], axis=0) + if w & 1: + arr = _np.concatenate([arr, arr[:, -1:]], axis=1) + quad = (arr[0:h2 * 2:2, 0:w2 * 2:2].astype(_np.uint16) + + arr[0:h2 * 2:2, 1:w2 * 2 + 1:2] + + arr[1:h2 * 2 + 1:2, 0:w2 * 2:2] + + arr[1:h2 * 2 + 1:2, 1:w2 * 2 + 1:2]) + return (quad >> 2).astype(_np.uint8).tobytes() + except ImportError: + pass + + out = bytearray(w2 * h2 * 4) + for y in range(h2): + y0 = y * 2 + y1 = min(y0 + 1, h - 1) + for x in range(w2): + x0 = x * 2 + x1 = min(x0 + 1, w - 1) + total = [0, 0, 0, 0] + for yy in (y0, y1): + r1 = yy * w * 4 + for xx in (x0, x1): + o = r1 + xx * 4 + total[0] += rgba[o] + total[1] += rgba[o + 1] + total[2] += rgba[o + 2] + total[3] += rgba[o + 3] + o = (y * w2 + x) * 4 + for c in range(4): + out[o + c] = total[c] >> 2 + return bytes(out) + + +def encode_rx2_texture(template, width, height, rgba, min_mip=128): + """Build a complete RX2 file from an RGBA image (pure Python encoder). + + ``template`` is the byte content of a known-good RX2 container; its + header and file table are reused verbatim and the payload is replaced + with a freshly encoded DXT5 mip chain. ``rgba`` is ``width * height * 4`` + row-major RGBA bytes (apply any opacity to the alpha channel before + calling). Mips are generated down to ``min_mip`` (the X360 swizzle is + only exact at widths of 32 block units, i.e. 128px). + + Returns the complete RX2 file as bytes. + """ + rx2 = RX2File(template) + rx2.parse() + entry = next((e for e in rx2.entries if e.is_texture), None) + if entry is None: + raise RX2ParseError("the container template has no texture entry") + if entry.info_offset is None or entry.info_offset + 40 > rx2.data_base: + raise RX2ParseError("the container template stores its texture info " + "outside the header region") + if len(rgba) < width * height * 4: + raise RX2ParseError("RGBA buffer is too small for %dx%d" % (width, height)) + if (width & (width - 1)) or (height & (height - 1)): + raise RX2ParseError("only square power-of-two textures are supported") + if width != height: + raise RX2ParseError("only square textures are supported") + if not 16 <= width <= 4096: + raise RX2ParseError("unsupported texture size %dx%d" % (width, height)) + + mips = [] + w, h = width, height + cur = rgba + while True: + mips.append((w, h, cur)) + if max(w, h) <= min_mip: + break + w2, h2 = max(1, w >> 1), max(1, h >> 1) + cur = _box_downsample(cur, w, h, w2, h2) + w, h = w2, h2 + + chain = b"".join(encode_dxt5(m, w, h) for (w, h, m) in mips) + + out = bytearray(template[:rx2.data_base]) + out += chain + p = entry.info_offset + out[p + 28:p + 32] = struct.pack(">I", 64 * (len(mips) - 1)) + out[p + 32:p + 36] = struct.pack(">I", 0xA00 + max(0x4000, width * width)) + out[p + 35] = FMT_DXT5 + # The 32-bit size field packs width:13 = w-1 (bits 0-12) and + # height:13 = h-1 (bits 13-25); the noesis plugin reads its byte 37 as + # (h-1)>>3 and the width H with the (h-1)&7 bits masked off, which both + # fall out of this single dword. + out[p + 36:p + 40] = struct.pack(">I", ((height - 1) << 13) | (width - 1)) + f2_pos = rx2.file_table_offset + (entry.index - 1) * 24 + 8 + out[f2_pos:f2_pos + 4] = struct.pack(">I", len(chain)) + return bytes(out) + + +def _decode_texture_data(raw, width, height, fmt, dxt5_variant): + if fmt == FMT_DXT1: + return decode_dxt1(raw, width, height) + if fmt == FMT_DXT3: + return decode_dxt3(raw, width, height) + if fmt == FMT_DXT5: + return decode_dxt5(raw, width, height, tiled=dxt5_variant not in (1, 2, 84)) + if fmt == FMT_ATI2: + return decode_ati2(raw, width, height) + if fmt == FMT_DXT1_NORMAL: + return decode_dxt1_normal(raw, width, height) + if fmt == FMT_A8R8G8B8: + return _decode_raw_a8r8g8b8(_untile360_raw(raw, width, height, 4), width, height) + if fmt == FMT_B5G6R5: + return _decode_raw_b5g6r5(_untile360_raw(_swap16(raw), width, height, 2), + width, height) + if fmt == FMT_A8: + return _decode_raw_a8(_untile360_raw(raw, width, height, 1), width, height) + raise RX2ParseError("unhandled texture format 0x%02X" % fmt) + + +def _main(argv=None): + import argparse + parser = argparse.ArgumentParser(description="Parse an EA Skate RX2 file.") + parser.add_argument("input", help="path to the .rx2 / .dat file") + parser.add_argument("--toc", action="store_true", help="dump the file table") + parser.add_argument("--out-dir", help="save every texture as PNG into this directory") + args = parser.parse_args(argv) + + rx2 = parse_rx2(args.input) + print(rx2) + for w in rx2.warnings: + print("WARNING:", w) + if args.toc: + for entry in rx2.entries: + print(" ", entry) + for tex in rx2.textures: + print(" ", tex) + if args.out_dir: + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + for tex in rx2.textures: + p = out / (tex.name + ".png") + tex.save_png(p) + print("saved", p) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main())