Merge retail scenery, lighting and travel with current map lifecycle

This commit is contained in:
chazmm committed 2026-09-08 20:54:00 +01:00
commit fb6f6eddfb
56 files changed
+3958 -247

No files matched your search

+12 -1
View File
@@ -1,7 +1,18 @@
fn main() {
let mut failed = false;
let mut render_only = false;
for path in std::env::args_os().skip(1) {
match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) {
if path == "--render-only" {
render_only = true;
continue;
}
let result = if render_only {
std::fs::read(&path).map_err(|e| e.to_string())
.and_then(|bytes| skate_data::skate_map::SkateMap::parse_render_only(&bytes))
} else {
skate_data::skate_map::SkateMap::load(std::path::Path::new(&path))
};
match result {
Ok(m) => {
println!(
"{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}",
+17 -1
View File
@@ -368,6 +368,22 @@ impl SkateMap {
/// Data tools can request one worker for reproducible serial comparisons.
/// The decoder caps concurrency at eight and skips threading for small maps.
pub fn parse_with_decode_workers(data: &[u8], workers: usize) -> Result<Self, String> {
Self::parse_inner(data, workers, true)
}
/// Decode a supplemental presentation package without requiring collision.
/// Playable map loading continues to use `parse`/`load`.
pub fn parse_render_only(data: &[u8]) -> Result<Self, String> {
let map = Self::parse_inner(data, 4, false)?;
if !map.geometry.collision.is_empty() || !map.rails.is_empty()
|| !map.doors.is_empty() || !map.lights.is_empty() || !map.routes.is_empty()
|| map.extensions.iter().any(|e| e.tag != *b"WMET")
{
return Err("SKATE render-only package contains non-presentation data".into());
}
Ok(map)
}
fn parse_inner(data: &[u8], workers: usize, require_collision: bool) -> Result<Self, String> {
let parse_started = Instant::now();
let mut r = Reader { bytes: data, at: 0 };
let magic = r.take(8)?;
@@ -671,7 +687,7 @@ impl SkateMap {
});
}
}
if geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) {
if require_collision && geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) {
return Err("SKATE requires triangle collision or an embedded RWCM archive".into());
}
if r.at != data.len() {
+17
View File
@@ -1,5 +1,22 @@
use skate_data::skate_map::SkateMap;
#[test]
fn presentation_loading_does_not_relax_playable_map_validation() {
let mut data = fixture(1);
let counts = 8 + 4 + 4 + "Fixture".len() + (4 + 12) * 4;
data[counts + 16..counts + 24].fill(0); // No collision or rails.
let needle: Vec<u8> = [0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes).collect();
let indices = data.windows(12).position(|w| w == needle).unwrap();
data.truncate(indices + 12);
assert!(SkateMap::parse(&data).unwrap_err().contains("requires triangle collision"));
let render = SkateMap::parse_render_only(&data).unwrap();
assert_eq!(render.geometry.indices, [0, 1, 2]);
assert!(render.geometry.collision.is_empty());
data[indices..indices+4].copy_from_slice(&99u32.to_le_bytes());
assert!(SkateMap::parse_render_only(&data).unwrap_err().contains("indices"));
assert!(SkateMap::parse_render_only(&fixture(1)).unwrap_err().contains("non-presentation"));
}
fn u(b: &mut Vec<u8>, value: u32) {
b.extend(value.to_le_bytes());
}
+1
View File
@@ -110,6 +110,7 @@ pub(crate) fn build(
));
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
app.add_plugins(crate::custom_models::CustomModelsPlugin);
crate::teleport_menu::install(&mut app);
app.add_plugins(crate::updater::UpdaterPlugin);
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
app.add_plugins(crate::scoring_hud::ScoringHudPlugin);
+10
View File
@@ -12,6 +12,7 @@ pub(crate) struct Config {
pub start_paused: bool,
pub multiplayer: crate::multiplayer::Options,
pub map_fingerprint: u64,
pub teleport: Option<String>,
}
impl Config {
@@ -26,6 +27,7 @@ impl Config {
start_paused: false,
multiplayer: crate::multiplayer::Options::default(),
map_fingerprint: 0,
teleport: None,
};
let mut difficulty_override = None;
let mut explicit_map = false;
@@ -67,6 +69,7 @@ impl Config {
Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; }
Some("--check-assets") => config.check_assets = true,
Some("--start-paused") => config.start_paused = true,
Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()),
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())?);
@@ -101,6 +104,13 @@ impl Config {
}
config.map_fingerprint = map_fingerprint(config.map_path.as_deref())?;
if config.multiplayer.direct.is_some() && config.multiplayer.session==0 {return Err("Direct multiplayer requires --net-session (a nonzero number shared by both players)".into());}
if let Some(id) = &config.teleport {
let destinations = crate::teleport_menu::load(&config.asset_root)?;
let target = destinations.iter().find(|d| &d.id == id).ok_or("Unknown teleport destination")?;
if target.matrix.is_none() || !config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &target.map)) {
return Err("Teleport destination is unavailable or belongs to a different map".into());
}
}
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());
+1 -1
View File
@@ -239,7 +239,7 @@ impl Plugin for CustomiserPlugin {
);
}
}
fn navigation(mut nav: ResMut<Navigation>, time: Res<Time<Real>>, keys: Res<ButtonInput<KeyCode>>) {
pub(crate) fn navigation(mut nav: ResMut<Navigation>, time: Res<Time<Real>>, keys: Res<ButtonInput<KeyCode>>) {
let pad = (0..4).find_map(|i| crate::input::platform::poll(i).ok());
// Remap outside the dead zone so a resting stick cannot drift the preview.
let axis = pad.as_ref().map_or(0., |p| (p.state.right[0] as f32 / 32767.).clamp(-1., 1.));
+8 -4
View File
@@ -218,7 +218,7 @@ fn setup(
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("GAME MENU"),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..14 {
for i in 0..15 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),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)));
@@ -273,13 +273,14 @@ pub(crate) fn interact(
mut net: ResMut<crate::multiplayer::Multiplayer>,
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
mut updater: ResMut<crate::updater::Updater>,
mut travel: ResMut<crate::teleport_menu::Travel>,
) {
if transition.busy() {
menu.open = true;
time.pause();
return;
}
if customiser.open || custom_models.open { return; }
if travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
@@ -305,7 +306,7 @@ pub(crate) fn interact(
}
}
if menu.open {
let rows = if menu.multiplayer { 11 } else { 14 };
let rows = if menu.multiplayer { 11 } else { 15 };
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + rows - 1) % rows;
}
@@ -425,6 +426,7 @@ pub(crate) fn interact(
}
12 => custom_models.begin(),
13 => menu.status = updater.open(false),
14 => travel.open = true,
_ => {}
}
}
@@ -509,6 +511,7 @@ fn labels(
time: Res<Time<Real>>,
customiser: Res<crate::customiser::Customiser>,
custom_models: Res<crate::custom_models::CustomModels>,
travel: Res<crate::teleport_menu::Travel>,
net: Res<crate::multiplayer::Multiplayer>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
@@ -516,7 +519,7 @@ fn labels(
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
) {
root.display = if menu.open && !customiser.open && !custom_models.open {
root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open {
Display::Flex
} else {
Display::None
@@ -610,6 +613,7 @@ fn labels(
10 => "Character customiser".into(),
12 => "Custom models".into(),
13 => "Updates".into(),
14 => "Teleport…".into(),
_ => "Multiplayer".into(),
}
};
+3
View File
@@ -34,8 +34,11 @@ mod customiser;
mod customiser_parts;
mod customiser_material;
mod custom_models;
mod teleport_menu;
mod render_capacity;
mod retail_render;
mod retail_character;
mod retail_irradiance;
mod presentation;
mod replay;
mod world;
+6 -2
View File
@@ -18,6 +18,9 @@ pub(crate) struct StagedAssets<A: Asset> {
values: Vec<(Handle<A>, A)>,
}
impl<A: Asset> StagedAssets<A> {
pub fn iter_mut(&mut self) -> impl Iterator<Item = (AssetId<A>, &mut A)> {
self.values.iter_mut().map(|(h, a)| (h.id(), a))
}
fn new(world: &World) -> Self {
Self { provider: world.resource::<Assets<A>>().get_handle_provider(), values: Vec::new() }
}
@@ -92,9 +95,10 @@ impl PreparedScene {
});
if let Some(map) = map {
crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes,
&mut self.materials, &mut self.retail, &mut self.images);
&mut self.materials, &mut self.retail, &mut self.images, &crate::retail_render::MaterialTuning::load(root));
crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images);
crate::retail_render::spawn_sky(&map.name, root, &mut self.commands,
&mut self.meshes, &mut self.images, &mut self.sky);
&mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail);
} else {
crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials);
}
+5
View File
@@ -87,6 +87,11 @@ pub(crate) struct SkaterRuntime {
}
impl SkaterRuntime {
pub(crate) fn travel_to(&mut self, transform: [[f32; 4]; 4]) -> Result<(), String> {
self.player_input.request_teleport(transform)?;
self.teleport_state.request_manual(transform, true);
Ok(())
}
pub fn load(
asset_root: &Path,
graphs: &StockGraphs,
@@ -32,6 +32,10 @@ impl Runtime {
self.manual_on_board = Some(on_board);
}
pub fn take_manual_on_board(&mut self) -> Option<bool> { self.manual_on_board.take() }
pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) {
self.checkpoint = checkpoint;
self.pending_reply = None;
}
pub fn new(checkpoint: Checkpoint) -> Self {
Self {
state: TeleportState::default(),
+51
View File
@@ -0,0 +1,51 @@
//! Authored presentation supplements outside the static district geometry.
use bevy::prelude::*;
use skate_data::skate_map::SkateMap;
use std::path::Path;
pub(crate) fn spawn_backdrop(
name: &str,
asset_root: &Path,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
retail_materials: &mut impl crate::map_render::AssetSink<super::RetailWorldMaterial>,
images: &mut impl crate::map_render::AssetSink<Image>,
) {
for folder in ["native-backdrops", "native-props"] {
spawn_package(name, asset_root, folder, commands, meshes, materials, retail_materials, images);
}
}
fn spawn_package(
name: &str,
asset_root: &Path,
folder: &str,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
retail_materials: &mut impl crate::map_render::AssetSink<super::RetailWorldMaterial>,
images: &mut impl crate::map_render::AssetSink<Image>,
) {
let path = asset_root.join("private").join(folder).join(format!("{name}.skate"));
if !path.is_file() {
return;
}
let map = match std::fs::read(&path).map_err(|e| e.to_string())
.and_then(|data| SkateMap::parse_render_only(&data)) {
Ok(map) => map,
Err(error) => {
error!("SKATE_BACKDROP: {}: {error}", path.display());
return;
}
};
// This package contributes presentation only; never route it into physics.
if map.name != name || !map.geometry.collision.is_empty()
|| !map.lights.is_empty() || !map.doors.is_empty() || !map.rails.is_empty()
{
error!("SKATE_BACKDROP: invalid render-only package {}", path.display());
return;
}
info!("SKATE_BACKDROP: {name} {folder} triangles={}", map.geometry.indices.len() / 3);
crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root));
}
+339
View File
@@ -0,0 +1,339 @@
//! Character SH/key/rim shader, independent of the world's additive lights.
use crate::retail_irradiance::Irradiance;
use bevy::{
asset::embedded_asset,
camera::visibility::RenderLayers,
gltf::GltfMaterialName,
prelude::*,
render::render_resource::{AsBindGroup, ShaderType},
shader::ShaderRef,
};
use serde::Deserialize;
use std::collections::HashMap;
pub(crate) struct CharacterLightingPlugin;
impl Plugin for CharacterLightingPlugin {
fn build(&self, app: &mut App) {
embedded_asset!(app, "retail_character.wgsl");
embedded_asset!(app, "retail_character_depth.wgsl");
app.add_plugins(MaterialPlugin::<CharacterMaterial>::default())
.add_systems(Startup, load)
.add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(crate::retail_render::world_changed))
.add_systems(Update, (bind, shadow_views, update).chain());
}
}
#[derive(Deserialize)]
struct MaterialData {
shader: String,
params: Vec<[f32; 4]>,
specular: Option<String>,
}
#[derive(Deserialize)]
struct LightingData {
materials: HashMap<String, MaterialData>,
default_sh: [[f32; 3]; 9],
}
#[derive(Resource)]
struct Lighting {
data: LightingData,
probes: Irradiance,
light: Vec4,
display_sh: Option<[Vec4; 9]>,
}
#[derive(Clone, ShaderType)]
struct CharacterParams {
light: Vec4,
tint: Vec4,
// normal map present, dedicated specular mask, alpha cutoff, hair family
options: Vec4,
rows: [Vec4; 9],
sh: [Vec4; 9],
}
#[derive(Asset, TypePath, AsBindGroup, Clone)]
struct CharacterMaterial {
#[uniform(0)]
params: CharacterParams,
#[texture(1)]
#[sampler(2)]
diffuse: Option<Handle<Image>>,
#[texture(3)]
#[sampler(4)]
normal: Option<Handle<Image>>,
#[texture(5)]
#[sampler(6)]
mask: Option<Handle<Image>>,
alpha: AlphaMode,
}
impl Material for CharacterMaterial {
fn fragment_shader() -> ShaderRef {
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character.wgsl")).with_source("embedded").into()
}
fn prepass_fragment_shader() -> ShaderRef {
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character_depth.wgsl")).with_source("embedded").into()
}
fn alpha_mode(&self) -> AlphaMode {
self.alpha
}
}
#[derive(Component)]
struct ShadowSource;
fn load(mut commands: Commands, config: Res<crate::config::Config>, sources: Query<Entity, With<ShadowSource>>, mut shadow: ResMut<crate::retail_render::ShadowState>) {
commands.remove_resource::<Lighting>();
for e in &sources { commands.entity(e).despawn(); }
*shadow = Default::default();
let Some(map_path) = &config.map_path else {
return;
};
let path = config.asset_root.join("private/character-lighting.json");
let data = match std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|b| serde_json::from_slice::<LightingData>(&b).map_err(|e| e.to_string()))
{
Ok(data) => data,
Err(e) => {
warn!("SKATE_CHARACTER_LIGHTING: {e}");
return;
}
};
let name = map_path.file_stem().unwrap_or_default().to_string_lossy();
let overlay = config
.asset_root
.join("private/native-lighting")
.join(format!("{name}.irradiance"));
let probe_path = if overlay.is_file() {
overlay
} else {
map_path.with_extension("irradiance")
};
let probes = match std::fs::read(probe_path)
.map_err(|e| e.to_string())
.and_then(|b| Irradiance::parse(&b))
{
Ok(probes) => probes,
Err(e) => {
warn!("SKATE_CHARACTER_LIGHTING: spatial data unavailable: {e}");
return;
}
};
let sky_path = config
.asset_root
.join("private/native-skies")
.join(format!("{name}.json"));
let sky: serde_json::Value = match std::fs::read(sky_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
{
Some(sky) => sky,
None => {
warn!("SKATE_CHARACTER_LIGHTING: missing authored light direction");
return;
}
};
let Some(sun) = sky["environment"]["sun_direction"].as_array() else {
return;
};
if sun.len() != 3 {
return;
}
let mut light = Vec4::new(0., 0., 0., 2.5); // Existing retail scene exposure.
for i in 0..3 {
let Some(v) = sun[i].as_f64() else {
return;
};
light[i] = v as f32;
}
info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters");
spawn_shadow_sources(&mut commands, light.truncate());
commands.insert_resource(Lighting {
data,
probes,
light,
display_sh: None,
});
}
fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) {
// World + skater casters, sampled only by the character shader.
commands.spawn((
ShadowSource,
Name::new("Character shadow visibility"),
DirectionalLight {
illuminance: 0.,
shadows_enabled: true,
affects_lightmapped_mesh_diffuse: false,
..default()
},
Transform::default().looking_to(-light, Vec3::Y),
bevy::light::CascadeShadowConfigBuilder {
maximum_distance: 100.,
first_cascade_far_bound: 10.,
..default()
}
.build(),
));
// Only skinned player pieces inhabit layer 28. The world receiver samples
// this separate map so baked building/terrain shadows are not re-applied.
commands.spawn((
ShadowSource,
Name::new("Player shadow onto baked world"),
DirectionalLight {
illuminance: 0.,
shadows_enabled: true,
affects_lightmapped_mesh_diffuse: true,
// Receiver-only map: no self-shadow acne to hide with large bias.
shadow_depth_bias: 0.002,
shadow_normal_bias: 0.0,
..default()
},
RenderLayers::layer(28),
Transform::default().looking_to(-light, Vec3::Y),
bevy::light::CascadeShadowConfigBuilder {
num_cascades: 1,
maximum_distance: 24.,
..default()
}
.build(),
));
}
fn bind(
mut commands: Commands,
lighting: Option<Res<Lighting>>,
source: Res<Assets<StandardMaterial>>,
server: Res<AssetServer>,
mut materials: ResMut<Assets<CharacterMaterial>>,
entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d<StandardMaterial>)>,
) {
let Some(lighting) = lighting else {
return;
};
for (entity, name, handle) in &entities {
let Some(data) = lighting.data.materials.get(&name.0) else {
continue;
};
if data.params.len() != 9 {
continue;
}
let Some(m) = source.get(&handle.0) else {
continue;
};
let alpha_cutoff = if let AlphaMode::Mask(cutoff) = m.alpha_mode {
cutoff
} else {
-1.
};
let material = materials.add(CharacterMaterial {
params: CharacterParams {
light: lighting.light,
tint: Vec4::from_array(m.base_color.to_linear().to_f32_array()),
options: Vec4::new(
f32::from(m.normal_map_texture.is_some()),
f32::from(data.specular.is_some()),
alpha_cutoff,
f32::from(data.shader == "character.hair"),
),
rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])),
sh: lighting
.data
.default_sh
.map(|v| Vec3::from_array(v).extend(0.)),
},
diffuse: m.base_color_texture.clone(),
normal: m.normal_map_texture.clone(),
mask: data.specular.as_ref().map(|path| {
server.load_with_settings(
path.clone(),
|settings: &mut bevy::image::ImageLoaderSettings| {
settings.is_srgb = false;
},
)
}),
alpha: m.alpha_mode,
});
commands
.entity(entity)
.remove::<MeshMaterial3d<StandardMaterial>>()
.insert((
MeshMaterial3d(material),
RenderLayers::from_layers(&[0, 28]),
));
}
}
fn shadow_views(
mut commands: Commands,
lighting: Option<Res<Lighting>>,
cameras: Query<(Entity, Option<&RenderLayers>), With<Camera3d>>,
) {
if lighting.is_none() {
return;
}
for (entity, layers) in &cameras {
let layers = layers.cloned().unwrap_or_default();
if !layers.intersects(&RenderLayers::layer(28)) {
commands.entity(entity).insert(layers.with(28));
}
}
}
fn update(
lighting: Option<ResMut<Lighting>>,
root: Query<&Transform, With<crate::world::PlayerRoot>>,
mut materials: ResMut<Assets<CharacterMaterial>>,
mut shadow: ResMut<crate::retail_render::ShadowState>,
time: Res<Time>,
) {
let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else {
return;
};
let fallback = lighting
.data
.default_sh
.map(|v| Vec3::from_array(v).extend(0.));
let sh = lighting.probes.sample(root.translation, fallback);
let displayed = lighting.display_sh.map_or(sh, |old| {
let weight = 1. - (-time.delta_secs().clamp(0., 0.05) / 0.35).exp();
std::array::from_fn(|i| old[i].lerp(sh[i], weight))
});
for (_, material) in materials.iter_mut() {
material.params.sh = displayed;
}
lighting.display_sh = Some(displayed);
// Adapter floor: the local probe's direction-independent ambient term.
// The native per-frame c8 shadow-colour controller remains unrecovered.
shadow.approach(sh[0].truncate(), time.delta_secs());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn world_receiver_source_excludes_world_casters_and_emits_no_light() {
let mut world = World::new();
let mut queue = bevy::ecs::world::CommandQueue::default();
spawn_shadow_sources(&mut Commands::new(&mut queue, &world), Vec3::Y);
queue.apply(&mut world);
let player = RenderLayers::from_layers(&[0, 28]);
let terrain = RenderLayers::default();
let mut receivers = 0;
let mut character_sources = 0;
for (light, layers, cascades) in world
.query::<(&DirectionalLight, Option<&RenderLayers>, &bevy::light::CascadeShadowConfig)>()
.iter(&world)
{
let layers = layers.cloned().unwrap_or_default();
assert_eq!(light.illuminance, 0.);
assert!(light.shadows_enabled);
assert!(layers.intersects(&player));
if light.affects_lightmapped_mesh_diffuse {
assert!(!layers.intersects(&terrain));
assert_eq!(cascades.bounds, vec![24.]);
assert_eq!(light.shadow_normal_bias, 0.);
assert!(light.shadow_depth_bias < 0.005);
receivers += 1;
} else {
assert!(layers.intersects(&terrain));
character_sources += 1;
}
}
assert_eq!((receivers, character_sources), (1, 1));
}
}
@@ -0,0 +1,76 @@
#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame}
#import bevy_pbr::shadows::fetch_directional_shadow
struct CharacterParams {
light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>,
rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: CharacterParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var normal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler;
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let albedo = textureSample(diffuse,diffuse_sampler,i.uv);
// GLTF diffuse is sampled as sRGB; recover retail's gamma-two decode.
let gamma = select(albedo.rgb*12.92, 1.055*pow(max(albedo.rgb,vec3<f32>(0.0)),vec3<f32>(1.0/2.4))-0.055, albedo.rgb>vec3<f32>(0.0031308));
let d = gamma*gamma*p.tint.rgb;
let n = normalize(i.world_normal);
let rpos = i.world_position.xyz-frame::view.world_position;
let vd = -normalize(rpos);
if p.options.w>0.0 {
// Native hair response. The installed GLB supplies the selected dye
// and composited strand coverage; live CAC colour rows are not present.
let ndl=saturate(dot(n,p.light.xyz));
let fres=pow(1.0-saturate(dot(n,vd)),max(p.rows[2].w,1.0));
let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25)
+p.rows[6].rgb*fres*saturate(ndl*1.75+0.25);
if albedo.a<p.options.z { discard; }
return vec4<f32>(d*hl*p.light.w,albedo.a*p.tint.a);
}
let dp1=dpdx(rpos); let dp2=dpdy(rpos);
let du1=dpdx(i.uv); let du2=dpdy(i.uv);
var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x;
var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y;
tt *= -inverseSqrt(max(dot(tt,tt),1e-12));
bb *= inverseSqrt(max(dot(bb,bb),1e-12));
var vn=n;
if p.options.x>0.0 {
// Installed normal PNGs already unpack DXT5nm into RGB.
let nm=textureSample(normal_map,normal_sampler,i.uv).xy*2.0-1.0;
vn=normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm))));
}
var smask=albedo.a;
if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; }
let s=p.rows[7].y;
let v=vn*s;
let irr=saturate(p.sh[0].rgb+v.x*p.sh[1].rgb+v.y*p.sh[2].rgb+v.z*p.sh[3].rgb
+v.x*v.z*p.sh[4].rgb+v.z*v.y*p.sh[5].rgb+v.y*v.x*p.sh[6].rgb
+(3.0*v.z*v.z-1.0)*p.sh[7].rgb+(v.x*v.x-v.y*v.y)*p.sh[8].rgb);
let ndl=dot(vn,p.light.xyz);
let view_z=(frame::view.view_from_world*i.world_position).z;
var shadow=1.0;
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
// All-caster visibility light does not affect lightmapped world diffuse.
if (frame::lights.directional_lights[light_id].flags & 5u)==1u {
shadow=fetch_directional_shadow(light_id,i.world_position,vn,view_z);
break;
}
}
var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w;
let fb=1.0-saturate(dot(vn,vd));
let kfres=pow(max(fb,1e-6),p.rows[1].w);
let rfres=pow(max(fb,1e-6),p.rows[0].z);
let rd=normalize(p.light.xyz*vec3<f32>(-1.,0.2,-1.)-vd);
lit+=saturate(rfres*saturate(dot(vn,rd)))*p.rows[6].rgb;
let kr=p.light.xyz-2.0*ndl*vn;
let ks=pow(saturate(dot(vd,-kr)),p.rows[2].w)*select(0.0,1.0,ndl>=0.0)*shadow;
let rr=rd-2.0*dot(vn,rd)*vn;
let rs=pow(saturate(dot(vd,-rr)),p.rows[3].w)*rfres;
let spec=saturate((ks*p.rows[2].rgb*kfres+rs*p.rows[3].rgb)*smask*smask);
if albedo.a<p.options.z { discard; }
return vec4<f32>((d*lit+spec)*p.rows[0].y*p.light.w,albedo.a*p.tint.a);
}
@@ -0,0 +1,32 @@
#import bevy_pbr::prepass_io::VertexOutput
#ifdef PREPASS_FRAGMENT
#import bevy_pbr::prepass_io::FragmentOutput
#endif
#ifdef MOTION_VECTOR_PREPASS
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
#endif
struct CharacterParams { light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>, rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: CharacterParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@fragment
fn fragment(i: VertexOutput)
#ifdef PREPASS_FRAGMENT
-> FragmentOutput
#endif
{
if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; }
#ifdef PREPASS_FRAGMENT
var out: FragmentOutput;
#ifdef NORMAL_PREPASS
out.normal = vec4<f32>(normalize(i.world_normal)*0.5+0.5,1.0);
#endif
#ifdef MOTION_VECTOR_PREPASS
out.motion_vector = calculate_motion_vector(i.world_position,i.previous_world_position);
#endif
#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
out.frag_depth = i.unclipped_depth;
#endif
return out;
#endif
}
+1 -1
View File
@@ -5,7 +5,7 @@
#ifdef MOTION_VECTOR_PREPASS
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
#endif
struct WorldParams { mode: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32> }
struct WorldParams { mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
+274
View File
@@ -0,0 +1,274 @@
//! GPU-only exposure adaptation. Native evaluator, portable luminance sampling.
use bevy::{
asset::embedded_asset,
core_pipeline::{
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
},
ecs::query::QueryItem,
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
extract_component::ExtractComponentPlugin,
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
},
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::ViewTarget,
},
};
#[derive(Resource, Clone, ExtractResource)]
struct Settings {
tuning: Vec4,
timing: Vec4,
}
impl Default for Settings {
fn default() -> Self {
// Missing metadata retains the existing fixed exposure.
Self {
tuning: Vec4::new(0., 2.5, 2.5, 0.),
timing: Vec4::ZERO,
}
}
}
#[derive(serde::Deserialize)]
struct Authored {
target_luminance: f32,
min: f32,
max: f32,
damping: f32,
}
pub(super) fn install(app: &mut App) {
embedded_asset!(app, "retail_exposure.wgsl");
app.init_resource::<Settings>()
.add_plugins((
ExtractResourcePlugin::<Settings>::default(),
ExtractComponentPlugin::<super::RetailTone>::default(),
))
.add_systems(Startup, load)
.add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(super::world_changed))
.add_systems(Update, advance);
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render
.add_systems(RenderStartup, initialize)
.add_systems(Render, upload.in_set(RenderSystems::PrepareResources))
.add_render_graph_node::<ViewNodeRunner<ExposureNode>>(Core3d, ExposureLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::Tonemapping,
ExposureLabel,
Node3d::EndMainPassPostProcessing,
),
);
}
}
fn load(config: Res<crate::config::Config>, mut settings: ResMut<Settings>) {
*settings = Settings::default();
if std::env::var_os("SKATE_FIXED_EXPOSURE").is_some_and(|v| v == "1") {
info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode");
return;
}
let profile = config.map_path.as_ref().and_then(|p| p.file_stem()).and_then(|name| {
let bytes = std::fs::read(config.asset_root.join("private/exposure-profiles.json")).ok()?;
let mut profiles: std::collections::BTreeMap<String, Authored> = serde_json::from_slice(&bytes).ok()?;
profiles.remove(&name.to_string_lossy().to_lowercase())
});
let fallback = || std::fs::read(config.asset_root.join("private/exposure.json"))
.ok()
.and_then(|b| serde_json::from_slice::<Authored>(&b).ok());
if let Some(a) = profile.or_else(fallback) {
if [a.target_luminance, a.min, a.max, a.damping]
.iter()
.all(|x| x.is_finite())
&& a.min > 0.
&& a.max >= a.min
&& a.target_luminance > 0.
&& a.damping >= 0.
{
settings.tuning = Vec4::new(a.target_luminance, a.min, a.max, a.damping);
info!(
"RETAIL_EXPOSURE: authored target={} range={}..{} damping={}; portable GPU meter",
a.target_luminance, a.min, a.max, a.damping
);
}
}
}
fn advance(time: Res<Time>, mut settings: ResMut<Settings>) {
settings.timing.x = time.delta_secs().clamp(0., 0.05);
}
#[derive(Resource)]
struct Pipeline {
compute_layout: BindGroupLayoutDescriptor,
tone_layout: BindGroupLayoutDescriptor,
compute: CachedComputePipelineId,
tone: CachedRenderPipelineId,
settings: Buffer,
state: Buffer,
sampler: Sampler,
}
fn initialize(
mut commands: Commands,
device: Res<RenderDevice>,
cache: Res<PipelineCache>,
assets: Res<AssetServer>,
fullscreen: Res<FullscreenShader>,
) {
let compute_layout = BindGroupLayoutDescriptor::new(
"retail exposure meter",
&BindGroupLayoutEntries::sequential(
ShaderStages::COMPUTE,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
uniform_buffer::<[Vec4; 2]>(false),
storage_buffer::<Vec4>(false),
),
),
);
let tone_layout = BindGroupLayoutDescriptor::new(
"retail exposed tone",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
texture_2d(TextureSampleType::Float { filterable: true }),
sampler(SamplerBindingType::Filtering),
storage_buffer_read_only::<Vec4>(false),
),
),
);
let compute = cache.queue_compute_pipeline(ComputePipelineDescriptor {
label: Some("retail exposure".into()),
layout: vec![compute_layout.clone()],
shader: assets.load(bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_exposure.wgsl")).with_source("embedded")),
entry_point: Some("meter".into()),
..default()
});
let tone = cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("retail exposed tone".into()),
layout: vec![tone_layout.clone()],
vertex: fullscreen.to_vertex_state(),
fragment: Some(FragmentState {
shader: assets.load(bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_tone.wgsl")).with_source("embedded")),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
..default()
}),
..default()
});
let settings = device.create_buffer(&BufferDescriptor {
label: Some("retail exposure settings"),
size: 32,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let state = device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("retail exposure state"),
contents: &bytes([Vec4::new(2.5, 0., 0., 0.)]),
usage: BufferUsages::STORAGE,
});
commands.insert_resource(Pipeline {
compute_layout,
tone_layout,
compute,
tone,
settings,
state,
sampler: device.create_sampler(&SamplerDescriptor {
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
..default()
}),
});
}
fn bytes<const N: usize>(values: [Vec4; N]) -> Vec<u8> {
values
.into_iter()
.flat_map(|v| v.to_array())
.flat_map(f32::to_le_bytes)
.collect()
}
fn upload(settings: Res<Settings>, pipeline: Res<Pipeline>, queue: Res<RenderQueue>) {
queue.write_buffer(
&pipeline.settings,
0,
&bytes([settings.tuning, settings.timing]),
);
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct ExposureLabel;
#[derive(Default)]
struct ExposureNode;
impl ViewNode for ExposureNode {
type ViewQuery = (&'static ViewTarget, &'static super::RetailTone);
fn run<'w>(
&self,
_: &mut RenderGraphContext,
context: &mut RenderContext,
(view, _): QueryItem<Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let p = world.resource::<Pipeline>();
let cache = world.resource::<PipelineCache>();
let (Some(compute), Some(tone)) = (
cache.get_compute_pipeline(p.compute),
cache.get_render_pipeline(p.tone),
) else {
return Ok(());
};
let post = view.post_process_write();
let meter = context.render_device().create_bind_group(
"retail meter",
&cache.get_bind_group_layout(&p.compute_layout),
&BindGroupEntries::sequential((
post.source,
&p.sampler,
p.settings.as_entire_binding(),
p.state.as_entire_binding(),
)),
);
let output = context.render_device().create_bind_group(
"retail exposed tone",
&cache.get_bind_group_layout(&p.tone_layout),
&BindGroupEntries::sequential((post.source, &p.sampler, p.state.as_entire_binding())),
);
{
let mut pass = context
.command_encoder()
.begin_compute_pass(&ComputePassDescriptor {
label: Some("retail exposure meter"),
timestamp_writes: None,
});
pass.set_pipeline(compute);
pass.set_bind_group(0, &meter, &[]);
pass.dispatch_workgroups(1, 1, 1);
}
let mut pass = context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("retail exposed tone"),
color_attachments: &[Some(RenderPassColorAttachment {
view: post.destination,
depth_slice: None,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(tone);
pass.set_bind_group(0, &output, &[]);
pass.draw(0..3, 0..1);
Ok(())
}
}
@@ -0,0 +1,30 @@
// Native multiplicative evaluator (827F0D00) and centre weighting (827F0B78).
// Portable meter: 16x16 bilinear HDR samples, not the console resolve surface.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct Settings { tuning: vec4<f32>, timing: vec4<f32> }
@group(0) @binding(2) var<uniform> settings: Settings;
@group(0) @binding(3) var<storage, read_write> state: vec4<f32>;
var<workgroup> sums: array<f32, 256>;
@compute @workgroup_size(256)
fn meter(@builtin(local_invocation_index) id: u32) {
let uv=(vec2<f32>(f32(id%16u),f32(id/16u))+0.5)/16.0;
let xy=uv*2.0-1.0;
let weight=pow(1.0-abs(xy.x*xy.y),2.0);
// Material output currently includes baseline exposure 2.5. Meter the
// current adapted image, with a portable Rec.709 luminance conversion.
let c=max(textureSampleLevel(source,source_sampler,uv,0.0).rgb,vec3<f32>(0.0))*state.x/2.5;
sums[id]=min(dot(c,vec3<f32>(0.2126,0.7152,0.0722)),16.0)*weight;
workgroupBarrier();
for(var stride=128u;stride>0u;stride/=2u) {
if id<stride { sums[id]+=sums[id+stride]; }
workgroupBarrier();
}
if id==0u {
let average=sums[0]/256.0*2.515;
let factor=max(0.001,1.0+settings.tuning.w*(settings.tuning.x-average));
// Native evaluator ticks per frame; adapt to a 30 Hz time basis here.
state.x=clamp(state.x*pow(factor,settings.timing.x*30.0),settings.tuning.y,settings.tuning.z);
state.y=average;
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Spatial SH samples from RX2 0xEB0024. TU3 827E6AC8 selects a nearest sample.
use bevy::prelude::*;
#[derive(Clone)]
pub(crate) struct Probe {
pub position: Vec3,
pub sh: [Vec4; 9],
}
pub(crate) struct ProbeGroup {
min: Vec3,
max: Vec3,
probes: Vec<Probe>,
}
#[derive(Default)]
pub(crate) struct Irradiance {
groups: Vec<ProbeGroup>,
recent: Vec<Probe>,
}
impl Irradiance {
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
if bytes.get(..4) != Some(b"SHP1") {
return Err("Invalid irradiance header".into());
}
let mut at = 4;
fn word(bytes: &[u8], at: &mut usize) -> Result<u32, String> {
let raw = bytes.get(*at..*at + 4).ok_or("Truncated irradiance")?;
*at += 4;
Ok(u32::from_le_bytes(raw.try_into().unwrap()))
}
let count = word(bytes, &mut at)?;
let mut groups = Vec::new();
for _ in 0..count {
let n = word(bytes, &mut at)?;
let mut probes = Vec::new();
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for _ in 0..n {
let mut v = [0.; 39];
for x in &mut v {
*x = f32::from_bits(word(bytes, &mut at)?);
}
word(bytes, &mut at)?; // Runtime linked-list word, not lighting.
if !v.iter().all(|v| v.is_finite()) {
return Err("Non-finite irradiance".into());
}
let position = Vec3::from_slice(&v[36..39]);
min = min.min(position);
max = max.max(position);
probes.push(Probe {
position,
sh: std::array::from_fn(|i| Vec4::from_slice(&v[i * 4..i * 4 + 4])),
});
}
if !probes.is_empty() {
groups.push(ProbeGroup { min, max, probes });
}
}
if at != bytes.len() {
return Err("Trailing irradiance bytes".into());
}
Ok(Self {
groups,
recent: Vec::new(),
})
}
pub fn sample(&mut self, position: Vec3, fallback: [Vec4; 9]) -> [Vec4; 9] {
// Native six-entry cache is accepted inside 2.5 metres of a sample.
if let Some((i, _)) = self
.recent
.iter()
.enumerate()
.map(|(i, p)| (i, p.position.distance_squared(position)))
.filter(|(_, d)| *d < 6.25)
.min_by(|a, b| a.1.total_cmp(&b.1))
{
let probe = self.recent.remove(i);
let sh = probe.sh;
self.recent.insert(0, probe);
return sh;
}
let mut best = None;
let mut distance = f32::MAX;
for group in &self.groups {
if position.cmplt(group.min - Vec3::splat(15.)).any()
|| position.cmpgt(group.max + Vec3::splat(15.)).any()
{
continue;
}
for p in &group.probes {
let delta = (p.position - position).abs();
if delta.x > 15. || delta.z > 15. {
continue;
}
let d = p.position.distance_squared(position);
if d < distance {
distance = d;
best = Some(p.clone());
}
}
}
match best {
Some(probe) => {
let sh = probe.sh;
self.recent.insert(0, probe);
self.recent.truncate(6);
sh
}
None => fallback,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spatial_selection_and_outside_fallback() {
let mut b = b"SHP1".to_vec();
b.extend(1u32.to_le_bytes());
b.extend(2u32.to_le_bytes());
for (x, colour) in [(0f32, 0.2f32), (10., 0.8)] {
let mut v = [0f32; 40];
v[0] = colour;
v[36] = x;
for f in v {
b.extend(f.to_le_bytes());
}
}
let mut data = Irradiance::parse(&b).unwrap();
assert_eq!(data.sample(Vec3::ZERO, [Vec4::ZERO; 9])[0].x, 0.2);
assert_eq!(data.sample(Vec3::X * 9., [Vec4::ZERO; 9])[0].x, 0.8);
assert_eq!(data.sample(Vec3::X * 100., [Vec4::ONE; 9]), [Vec4::ONE; 9]);
assert!(Irradiance::parse(&b[..b.len() - 1]).is_err());
}
#[test]
fn cache_retains_native_two_point_five_metre_hysteresis() {
let a = Probe {
position: Vec3::ZERO,
sh: [Vec4::ZERO; 9],
};
let b = Probe {
position: Vec3::X * 3.,
sh: [Vec4::ONE; 9],
};
let mut data = Irradiance {
groups: vec![ProbeGroup {
min: Vec3::ZERO,
max: b.position,
probes: vec![a.clone(), b],
}],
recent: vec![a],
};
// B is closer, but A remains selected within the native cache radius.
assert_eq!(
data.sample(Vec3::X * 2., [Vec4::splat(2.); 9]),
[Vec4::ZERO; 9]
);
assert_eq!(
data.sample(Vec3::X * 2.5, [Vec4::splat(2.); 9]),
[Vec4::ONE; 9]
);
}
}
+149 -145
View File
@@ -1,14 +1,9 @@
//! Retail world shading. See docs/retail-renderer.md for provenance and gaps.
use bevy::{
asset::{AssetPath, embedded_asset, embedded_path},
core_pipeline::{
core_3d::graph::Node3d,
fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin},
},
prelude::*,
render::{
extract_component::ExtractComponent,
render_graph::{InternedRenderLabel, RenderLabel},
render_resource::{AsBindGroup, ShaderType},
},
shader::ShaderRef,
@@ -23,6 +18,15 @@ fn retail_shader(path: &str) -> ShaderRef {
}
impl Plugin for RetailRenderPlugin {
fn build(&self, app: &mut App) {
shadow::install(app);
exposure::install(app);
if std::env::var_os("SKATE_WEATHERING_COMPARE").is_some_and(|v| v == "1") {
app.add_systems(Update, compare_weathering);
}
app.add_plugins(crate::retail_character::CharacterLightingPlugin);
if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") {
eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only");
}
embedded_asset!(app, "retail_world.wgsl");
embedded_asset!(app, "retail_tone.wgsl");
embedded_asset!(app, "retail_depth.wgsl");
@@ -30,153 +34,67 @@ impl Plugin for RetailRenderPlugin {
app.add_plugins((
MaterialPlugin::<RetailWorldMaterial>::default(),
MaterialPlugin::<RetailSkyMaterial>::default(),
FullscreenMaterialPlugin::<RetailTone>::default(),
));
}
}
// Diagnostic only: mutate materials once per keypress, never continuously.
// Separate bits preserve the original texture-presence flags for restoration.
fn compare_weathering(
keys: Res<ButtonInput<KeyCode>>,
mut mode: Local<u32>,
mut materials: ResMut<Assets<RetailWorldMaterial>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
) {
if !keys.just_pressed(KeyCode::F8) { return; }
*mode = (*mode + 1) % 4;
let label = match *mode {
1 => "Repeating grime OFF; decals ON",
2 => "Repeating grime ON; decals OFF",
3 => "Repeating grime OFF; decals OFF",
_ => "Normal grime and decals ON",
};
for (_, material) in materials.iter_mut() {
let family = material.params.mode.x as u32;
if !(1..=8).contains(&family) { continue; }
let flags = material.params.mode.y as u32;
material.params.mode.y = ((flags & !768) | (*mode << 8)) as f32;
}
for mut window in &mut windows {
window.title = format!("Skate 3 - {label} [F8: next comparison]");
}
info!("WEATHERING_COMPARE: {label}");
}
#[path = "retail_exposure.rs"]
mod exposure;
#[path = "retail_shadow.rs"]
mod shadow;
pub(crate) use shadow::ShadowState;
#[derive(Resource)]
pub(crate) struct RetailScene(pub bool);
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub(crate) struct RetailSkyMaterial {
#[uniform(0)]
pub params: Vec4,
#[texture(1)]
#[sampler(2)]
pub diffuse: Handle<Image>,
}
impl Material for RetailSkyMaterial {
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn vertex_shader() -> ShaderRef {
retail_shader("retail_sky.wgsl")
}
fn fragment_shader() -> ShaderRef {
Self::vertex_shader()
}
fn specialize(
_: &bevy::pbr::MaterialPipeline,
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
_: &bevy::mesh::MeshVertexBufferLayoutRef,
_: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = None;
if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = false;
}
Ok(())
}
}
#[path = "retail_sky.rs"]
mod sky;
pub(crate) use sky::{spawn_sky, RetailSkyMaterial};
pub(crate) fn spawn_sky(
name: &str,
root: &std::path::Path,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
images: &mut impl crate::map_render::AssetSink<Image>,
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
) {
// Parks need their authored sky selection from the environment controller;
// only these three district identities are currently resolved.
if !matches!(name, "University" | "DownTown" | "Industrial") {
return;
}
#[derive(serde::Deserialize)]
struct Sky {
width: u32,
height: u32,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
}
let load = || -> Result<(Sky, Vec<u8>), String> {
let base = root.join("private/native-skies");
let sky: Sky = serde_json::from_slice(
&std::fs::read(base.join(format!("{name}.json"))).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
let rgba = std::fs::read(base.join(format!("{name}.rgba"))).map_err(|e| e.to_string())?;
if sky.width == 0
|| sky.height == 0
|| u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64
|| sky.positions.len() != sky.uvs.len()
|| sky
.indices
.iter()
.any(|&i| i as usize >= sky.positions.len())
{
return Err("Invalid retail sky dimensions/geometry".into());
}
Ok((sky, rgba))
};
let (sky, rgba) = match load() {
Ok(v) => v,
Err(e) => {
warn!("Retail sky unavailable: {e}");
return;
}
};
let mut image = Image::new(
bevy::render::render_resource::Extent3d {
width: sky.width,
height: sky.height,
depth_or_array_layers: 1,
},
bevy::render::render_resource::TextureDimension::D2,
rgba,
bevy::render::render_resource::TextureFormat::Rgba8Unorm,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
);
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
image.sampler = bevy::image::ImageSampler::Descriptor(sampler);
let mesh = Mesh::new(
bevy::mesh::PrimitiveTopology::TriangleList,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs)
.with_inserted_indices(bevy::mesh::Indices::U32(sky.indices));
commands.spawn((
Name::new("Retail sky dome"),
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(materials.add(RetailSkyMaterial {
params: Vec4::new(165., 2.5, 1., 0.),
diffuse: images.add(image),
})),
Transform::default(),
bevy::camera::visibility::NoFrustumCulling,
bevy::light::NotShadowCaster,
bevy::light::NotShadowReceiver,
));
}
#[path = "retail_backdrop.rs"]
mod backdrop;
pub(crate) use backdrop::spawn_backdrop;
#[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)]
pub(crate) struct RetailTone {
pub enabled: Vec4,
}
impl FullscreenMaterial for RetailTone {
fn fragment_shader() -> ShaderRef {
retail_shader("retail_tone.wgsl")
}
fn node_edges() -> Vec<InternedRenderLabel> {
vec![
Node3d::Tonemapping.intern(),
Self::node_label().intern(),
Node3d::EndMainPassPostProcessing.intern(),
]
}
}
#[derive(Clone, Debug, ShaderType)]
pub(crate) struct WorldParams {
// family, texture flags, alpha cutoff (-1 for opaque), exposure
pub mode: Vec4,
// Diagnostic solid foliage colour; w=0 keeps retail shading.
pub foliage_debug: Vec4,
// macro UV scale, opacity, detail UV scale, material multiplier
pub surface: Vec4,
// tree LM scale/floor/tint, proxy multiplier: reference day capture
@@ -184,6 +102,10 @@ pub(crate) struct WorldParams {
pub fog_ramp: Vec4,
pub fog_color: Vec4,
pub shadow_color: Vec4,
pub sun_direction: Vec4,
// Wear-only visual tuning; artwork retains unit opacity.
pub decal: Vec4,
pub water: [Vec4; 4],
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
@@ -214,6 +136,8 @@ pub(crate) struct RetailWorldMaterial {
pub specular: Option<Handle<Image>>,
#[texture(15, dimension = "cube")]
pub environment: Option<Handle<Image>>,
#[storage(16, read_only)]
pub shadow_state: Handle<bevy::render::storage::ShaderStorageBuffer>,
pub alpha: AlphaMode,
pub two_sided: bool,
}
@@ -263,6 +187,7 @@ pub(crate) struct Binding {
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct Definition {
pub shader: String,
pub family: u32,
pub flags: u32,
pub bindings: BTreeMap<String, Binding>,
@@ -287,8 +212,11 @@ impl Definition {
}
let mut r = Reader(bytes);
r.take(16)?;
let _shader = r.text()?;
let family = r.u32()?;
let shader = r.text()?;
let stored_family = r.u32()?;
// Older map packages retained the complete bindings but classified
// non-flowing water as unknown. Upgrade without re-exporting geometry.
let family = if matches!(shader.as_str(), "water.default" | "water.alpha" | "water.skatepark") { 33 } else { stored_family };
let flags = r.u32()?;
let mut bindings = BTreeMap::new();
for _ in 0..r.u32()? {
@@ -318,6 +246,7 @@ impl Definition {
return None;
}
Some(Self {
shader,
family,
flags,
bindings,
@@ -332,12 +261,19 @@ impl Definition {
.ok()
.filter(|v| v.is_finite())
}
pub fn supported(&self) -> bool {
(1..=12).contains(&self.family) || self.family == 13
pub fn supported(&self, tuning: &MaterialTuning) -> bool {
(1..=13).contains(&self.family) || match self.family {
14 | 32 => tuning.rows.get(&self.shader).is_some_and(|r| !r.is_empty()),
31 => tuning.pca_available && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 3),
30 => tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4),
33 => tuning.pca_available && self.bindings.contains_key("normal") && self.bindings.contains_key("normal2") && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4),
_ => false,
}
}
pub fn build(
&self,
m: &skate_data::skate_map::Material,
tuning: &MaterialTuning,
texture: &mut impl FnMut(u32, u8) -> Option<Handle<Image>>,
) -> RetailWorldMaterial {
let mut fetch = |role: &str, fallback: u32, clamp: bool| {
@@ -345,7 +281,7 @@ impl Definition {
texture(
b.map_or(fallback, |b| b.texture),
if clamp || b.is_some_and(|b| b.u == 1 && b.v == 1) {
4
if role == "decal" { 6 } else { 4 }
} else {
3
},
@@ -354,7 +290,7 @@ impl Definition {
let diffuse = fetch("diffuse", m.textures[0], false);
let lightmap = fetch("lightmap", m.textures[1], true);
let normal = fetch("normal", m.textures[2], false);
let detail = fetch("detail", 0, false);
let detail = fetch(if matches!(self.family, 31 | 33) { "normal2" } else { "detail" }, 0, false);
let macro_map = fetch("macrooverlay", 0, false);
let decal = fetch("decal", 0, self.family == 3);
let specular = fetch("specular", 0, false);
@@ -364,7 +300,7 @@ impl Definition {
let detail_scale = self.scalar("detailNormalUVScale").unwrap_or(0.);
let flags = u32::from(normal.is_some())
| (u32::from(detail.is_some() && detail_scale > 0.) << 1)
| (u32::from(macro_map.is_some() && macro_scale > 0. && macro_opacity > 0.) << 2)
| (u32::from(macro_map.is_some() && macro_scale > 0. && (macro_opacity > 0. || self.family == 31)) << 2)
| (u32::from(decal.is_some()) << 3)
| (u32::from(specular.is_some()) << 4)
| (u32::from(lightmap.is_some()) << 5)
@@ -372,24 +308,50 @@ impl Definition {
| (u32::from(detail.is_some()) << 7);
// scene.hlsl's retail world ALPHAREF is 30, not the portable 0.5.
let cutoff = 30. / 255.;
let alpha = match m.alpha_mode {
1 => AlphaMode::Mask(cutoff),
2 => AlphaMode::Blend,
let debug_foliage = matches!(self.family, 9 | 10)
&& std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1");
let tree_wall = m.retail_definition.as_deref().is_some_and(|bytes| {
bytes.windows(b"TreeWall".len()).any(|s| s == b"TreeWall")
});
let alpha = match (debug_foliage, m.alpha_mode) {
(true, _) => AlphaMode::Opaque,
(_, 1) => AlphaMode::Mask(cutoff),
(_, 2) => AlphaMode::Blend,
_ => AlphaMode::Opaque,
};
let alpha = if self.family == 32 || (matches!(self.family, 30 | 33) && self.shader.ends_with("alpha")) {
AlphaMode::Blend
} else { alpha };
let mut water = [Vec4::ZERO; 4];
if let Some(rows) = tuning.rows.get(&self.shader) {
for (to, from) in water.iter_mut().zip(rows) { *to = Vec4::from_array(*from); }
}
if self.family == 14 {
water[1] = Vec4::new(self.scalar("uAnimationSpeed").unwrap_or(0.), self.scalar("vAnimationSpeed").unwrap_or(0.), 0., 0.);
}
RetailWorldMaterial {
params: WorldParams {
mode: Vec4::new(
self.family as f32,
flags as f32,
if m.alpha_mode == 1 { cutoff } else { -1. },
if m.alpha_mode == 1 && !debug_foliage { cutoff } else { -1. },
2.5,
),
foliage_debug: if !debug_foliage {
Vec4::ZERO
} else if tree_wall {
Vec4::new(0., 1., 1., 1.)
} else {
Vec4::new(1., 0., 1., 1.)
},
surface: Vec4::new(macro_scale, macro_opacity, detail_scale, 1.),
family: Vec4::new(0.3435, 0.02, 1., 0.45),
fog_ramp: Vec4::new(0., 0., 1., 0.),
fog_color: Vec4::ZERO,
shadow_color: Vec4::ZERO,
sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.),
decal: Vec4::new(stain_opacity(self.parameters.get("decal").and_then(|v| v.first()).map(String::as_str).unwrap_or("")), 0., 0., 0.),
water,
},
diffuse,
lightmap,
@@ -399,12 +361,36 @@ impl Definition {
decal,
specular,
environment,
shadow_state: shadow::BUFFER,
alpha,
two_sided: self.flags & 4 != 0,
}
}
}
// The source labels distinguish weathering from graphics. This is an explicit
// visual tuning choice, not a recovered native material constant. Keep arrows,
// logos, paint, scratches and edge wear at their authored alpha.
fn stain_opacity(texture_label: &str) -> f32 {
let label = texture_label.to_ascii_lowercase();
if ["grime", "grunge", "stain", "oildirt", "drainage", "ground_decals"]
.iter().any(|word| label.contains(word)) { 0.35 } else { 1. }
}
#[cfg(test)]
mod stain_tests {
use super::stain_opacity;
#[test]
fn weathering_tuning_preserves_artwork() {
for name in ["decal_WEAR_WaterStain_01", "subway_grunge03", "decal_GrimePuddle", "OT_Ground_decals"] {
assert_eq!(stain_opacity(name), 0.35);
}
for name in ["decal_Graphic_SP_UN_Shark_01", "decal_other_sp_arrowramps_01", "decal_Graphic_SP_UN_MegaRmp_01", "decal_Wear_GL_UN_MPedge_01", "decal_skateboardscratcheswood01", ""] {
assert_eq!(stain_opacity(name), 1.);
}
}
}
/// Native renderer box-filters decoded UNORM cube faces down to 1x1.
/// The same chain prevents aliasing of repeating world maps at distance.
pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (Vec<u8>, u32) {
@@ -444,3 +430,21 @@ pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (V
}
(bytes, count)
}
#[derive(Default)]
pub(crate) struct MaterialTuning {
rows: BTreeMap<String, Vec<[f32; 4]>>,
pca_available: bool,
}
impl MaterialTuning {
pub(crate) fn load(root: &std::path::Path) -> Self {
let path = root.join("private/render-parameters.json");
match std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<BTreeMap<String, Vec<[f32; 4]>>>(&b).ok()) {
Some(rows) if rows.values().flatten().flatten().all(|x| x.is_finite()) => Self { rows, pca_available: shadow::pca_available(root) },
_ => { warn!("Retail water/scroll tuning unavailable: {}", path.display()); Self::default() }
}
}
}
pub(crate) fn world_changed(mut messages: MessageReader<crate::map_transition::WorldChanged>) -> bool { messages.read().count() != 0 }
+122
View File
@@ -0,0 +1,122 @@
//! Shared frame state: shadow floor, animation clock and authored ocean PCA.
use bevy::{
prelude::*,
render::{
Render, RenderApp, RenderSystems,
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_asset::RenderAssets,
render_resource::BufferUsages,
renderer::RenderQueue,
storage::{GpuShaderStorageBuffer, ShaderStorageBuffer},
},
};
pub(super) const BUFFER: Handle<ShaderStorageBuffer> =
bevy::asset::uuid_handle!("cd736f89-4882-4a5d-8fcb-32273beeaaf4");
#[derive(Resource, Clone, Default, ExtractResource)]
pub(crate) struct ShadowState(pub Vec4, pub Vec4, pub [Vec4; 7]);
impl ShadowState {
pub(crate) fn approach(&mut self, target: Vec3, dt: f32) {
let target = target.clamp(Vec3::ZERO, Vec3::ONE);
let value = if self.0.w == 0. {
target
} else {
// Adapter smoothing, not a recovered native constant. Cap a hitch's
// contribution so one long frame cannot cause a darkness step.
self.0
.truncate()
.lerp(target, 1. - (-dt.clamp(0., 0.05) / 0.35).exp())
};
self.0 = value.extend(1.);
}
}
pub(super) fn install(app: &mut App) {
app.init_resource::<ShadowState>()
.add_plugins(ExtractResourcePlugin::<ShadowState>::default())
.add_systems(Startup, (initialize, load_pca))
.add_systems(Update, clock);
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(Render, upload.in_set(RenderSystems::PrepareResources));
}
}
fn initialize(mut buffers: ResMut<Assets<ShaderStorageBuffer>>) {
let mut buffer = ShaderStorageBuffer::from([Vec4::ZERO; 9]);
buffer.buffer_description.usage |= BufferUsages::COPY_DST;
buffers
.insert(BUFFER.id(), buffer)
.expect("reserved shadow state asset");
}
fn upload(
state: Res<ShadowState>,
buffers: Res<RenderAssets<GpuShaderStorageBuffer>>,
queue: Res<RenderQueue>,
) {
if let Some(buffer) = buffers.get(BUFFER.id()) {
let bytes: Vec<u8> = [state.0, state.1]
.into_iter()
.chain(state.2)
.flat_map(|v| v.to_array())
.flat_map(f32::to_le_bytes)
.collect();
// Keep the buffer and all material bind groups alive; upload 144 frame bytes.
queue.write_buffer(&buffer.buffer, 0, &bytes);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_transition_is_gradual_and_hitches_are_bounded() {
let mut state = ShadowState::default();
state.approach(Vec3::splat(0.1), 0.016);
assert_eq!(state.0, Vec3::splat(0.1).extend(1.));
state.approach(Vec3::splat(0.3), 0.016);
assert!(state.0.x > 0.1 && state.0.x < 0.12);
let mut hitch = state.clone();
state.approach(Vec3::splat(0.3), 0.05);
hitch.approach(Vec3::splat(0.3), 10.);
assert_eq!(state.0, hitch.0);
for _ in 0..240 {
state.approach(Vec3::splat(0.3), 1. / 60.);
}
assert!((state.0.x - 0.3).abs() < 0.0001);
}
}
#[derive(Resource, serde::Deserialize)]
struct OceanPca {
hz: f32,
frames: Vec<[[f32; 4]; 7]>,
}
fn read_pca(root: &std::path::Path) -> Option<OceanPca> {
let pca = std::fs::read(root.join("private/ocean-pca.json"))
.ok()
.and_then(|b| serde_json::from_slice::<OceanPca>(&b).ok())?;
(pca.hz == 30.
&& pca.frames.len() == 30
&& pca.frames.iter().flatten().flatten().all(|v| v.is_finite()))
.then_some(pca)
}
pub(super) fn pca_available(root: &std::path::Path) -> bool {
read_pca(root).is_some()
}
fn load_pca(mut commands: Commands, config: Res<crate::config::Config>) {
if let Some(pca) = read_pca(&config.asset_root) {
info!("RETAIL_OCEAN: loaded 30 authored PCA frames");
commands.insert_resource(pca);
}
}
fn clock(mut state: ResMut<ShadowState>, time: Res<Time>, pca: Option<Res<OceanPca>>) {
state.1.x = time.elapsed_secs();
if let Some(pca) = pca {
let frame = ((time.elapsed_secs_f64() * f64::from(pca.hz)) as usize) % pca.frames.len();
state.2 = pca.frames[frame].map(Vec4::from_array);
state.1.y = 1.;
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Authored camera-relative sky and its radial sun gradient.
use super::RetailWorldMaterial;
use bevy::{
prelude::*,
render::render_resource::{AsBindGroup, ShaderType},
shader::ShaderRef,
};
#[derive(Clone, Debug, ShaderType)]
pub(crate) struct SkyParams {
// anchor height, scene exposure, sky multiplier, sun angular scale (0 disables).
pub settings: Vec4,
pub sun_direction: Vec4,
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub(crate) struct RetailSkyMaterial {
#[uniform(0)]
pub params: SkyParams,
#[texture(1)]
#[sampler(2)]
pub diffuse: Handle<Image>,
#[texture(3)]
#[sampler(4)]
pub sun: Option<Handle<Image>>,
}
impl Material for RetailSkyMaterial {
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn vertex_shader() -> ShaderRef {
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_sky.wgsl")).with_source("embedded").into()
}
fn fragment_shader() -> ShaderRef {
Self::vertex_shader()
}
fn specialize(
_: &bevy::pbr::MaterialPipeline,
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
_: &bevy::mesh::MeshVertexBufferLayoutRef,
_: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = None;
if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = false;
}
Ok(())
}
}
#[derive(serde::Deserialize)]
struct FogFrame {
ramp: [f32; 4],
colour: [f32; 4],
}
#[derive(serde::Deserialize)]
struct Environment {
anchor_height: f32,
sun_direction: [f32; 3],
sun_scale: f32,
multiplier: f32,
location_chain: Vec<String>,
#[serde(default)]
fog_frame: Option<FogFrame>,
}
#[derive(serde::Deserialize)]
struct Sky {
width: u32,
height: u32,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
#[serde(default)]
sun_width: u32,
#[serde(default)]
sun_height: u32,
environment: Option<Environment>,
}
fn load(root: &std::path::Path, name: &str) -> Result<(Sky, Vec<u8>, Option<Vec<u8>>), String> {
if name.is_empty() || name.contains(['/', '\\', ':']) || matches!(name, "." | "..") {
return Err("Invalid retail sky name".into());
}
let base = root.join("private/native-skies");
let read = |suffix: &str| {
std::fs::read(base.join(format!("{name}{suffix}"))).map_err(|e| e.to_string())
};
let sky: Sky = serde_json::from_slice(&read(".json")?).map_err(|e| e.to_string())?;
let rgba = read(".rgba")?;
if sky.width == 0
|| sky.height == 0
|| u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64
|| sky.positions.is_empty()
|| sky.positions.len() != sky.uvs.len()
|| sky.indices.is_empty()
|| sky.indices.len() % 3 != 0
|| sky
.indices
.iter()
.any(|&i| i as usize >= sky.positions.len())
|| sky
.positions
.iter()
.flatten()
.chain(sky.uvs.iter().flatten())
.any(|v| !v.is_finite())
{
return Err("Invalid retail sky dimensions/geometry".into());
}
let sun = if let Some(env) = &sky.environment {
let direction = Vec3::from_array(env.sun_direction);
if !env.anchor_height.is_finite()
|| !direction.is_finite()
|| !(0.99..=1.01).contains(&direction.length_squared())
|| !env.sun_scale.is_finite()
|| env.sun_scale <= 0.
|| !env.multiplier.is_finite()
|| env.multiplier < 0.
|| sky.sun_width == 0
|| sky.sun_height != 16
{
return Err("Invalid authored sky environment".into());
}
if let Some(fog) = &env.fog_frame {
if !fog.ramp.iter().chain(fog.colour.iter()).all(|v| v.is_finite())
|| fog.ramp[0] < 0. || fog.ramp[2] <= 0.
|| !(-1. ..=0.).contains(&fog.colour[3]) {
return Err("Invalid authored fog frame".into());
}
}
let bytes = read(".sun.rgba")?;
if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 {
return Err("Invalid sun-gradient payload".into());
}
Some(bytes)
} else {
None
};
Ok((sky, rgba, sun))
}
fn image(rgba: Vec<u8>, width: u32, height: u32, repeat: bool) -> Image {
use bevy::render::render_resource::*;
let mut image = Image::new(
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
rgba,
TextureFormat::Rgba8Unorm,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
);
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
if repeat {
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
}
image.sampler = bevy::image::ImageSampler::Descriptor(sampler);
image
}
pub(crate) fn spawn_sky(
name: &str,
root: &std::path::Path,
commands: &mut crate::map_render::SceneCommands,
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
images: &mut impl crate::map_render::AssetSink<Image>,
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
world_materials: &mut crate::map_render::StagedAssets<RetailWorldMaterial>,
) {
let (sky, rgba, sun) = match load(root, name) {
Ok(v) => v,
Err(e) => {
warn!("Retail sky unavailable for {name}: {e}");
return;
}
};
// Legacy sky packages retain their original parameters. The scene exposure
// remains the adapter's capture default until auto-exposure is recovered.
let mut params = SkyParams {
settings: Vec4::new(165., 2.5, 1., 0.),
sun_direction: Vec4::ZERO,
};
if let Some(env) = &sky.environment {
params.settings = Vec4::new(env.anchor_height, 2.5, env.multiplier, env.sun_scale);
params.sun_direction = Vec3::from_array(env.sun_direction).extend(0.);
for (_, material) in world_materials.iter_mut() {
// Only the existing tangent-sign term uses this vector. No added
// directional light energy or imported-world shadow map.
material.params.sun_direction = params.sun_direction;
if let Some(fog) = &env.fog_frame {
material.params.fog_ramp = Vec4::from_array(fog.ramp);
material.params.fog_color = Vec4::from_array(fog.colour);
}
}
info!(
"SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}",
env.location_chain, env.anchor_height, env.sun_scale, env.multiplier, env.sun_direction
);
} else {
warn!(
"Retail sky {name} has legacy metadata; reconvert skies for authored parameters and sun"
);
}
let sun = sun.map(|bytes| images.add(image(bytes, sky.sun_width, sky.sun_height, false)));
let mesh = Mesh::new(
bevy::mesh::PrimitiveTopology::TriangleList,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs)
.with_inserted_indices(bevy::mesh::Indices::U32(sky.indices));
commands.spawn((
Name::new("Retail sky dome"),
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(materials.add(RetailSkyMaterial {
params,
diffuse: images.add(image(rgba, sky.width, sky.height, true)),
sun,
})),
Transform::default(),
bevy::camera::visibility::NoFrustumCulling,
bevy::light::NotShadowCaster,
bevy::light::NotShadowReceiver,
));
}
+23 -5
View File
@@ -1,20 +1,38 @@
#import bevy_pbr::{forward_io::Vertex, mesh_view_bindings::view}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> settings: vec4<f32>;
struct SkyParams { settings: vec4<f32>, sun_direction: vec4<f32> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: SkyParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var panorama: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var panorama_sampler: sampler;
struct SkyOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var sun_gradient: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var sun_sampler: sampler;
struct SkyOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) dome_position: vec3<f32>,
}
@vertex
fn vertex(v: Vertex) -> SkyOut {
var out: SkyOut;
let world = v.position + vec3<f32>(view.world_position.x,settings.x,view.world_position.z);
let world = v.position + vec3<f32>(view.world_position.x,p.settings.x,view.world_position.z);
out.position = view.clip_from_world*vec4<f32>(world,1.0);
// Reverse-Z far plane: the authored dome is larger than the world camera's far distance.
// Reverse-Z far plane: the authored dome is larger than the camera's far distance.
out.position.z = 0.0000001*out.position.w;
out.uv = v.uv;
out.dome_position = v.position;
return out;
}
@fragment
fn fragment(i: SkyOut) -> @location(0) vec4<f32> {
let d = textureSample(panorama,panorama_sampler,i.uv).rgb;
return vec4<f32>(d*d*settings.y*settings.z,1.0);
var linear = d*d;
if p.settings.w > 0.0 {
// Native sky_defaultPS: sine-angle lookup in the material's specular
// gradient, alpha controls the HDR core. World position minus the sky
// anchor is exactly the local dome position used here.
let dot_pl = saturate(dot(normalize(i.dome_position),p.sun_direction.xyz));
let sin_angle = sqrt(saturate(1.0-dot_pl*dot_pl));
let sun = textureSample(sun_gradient,sun_sampler,vec2<f32>(sin_angle/p.settings.w,0.5/16.0));
linear += sun.rgb*sun.rgb/saturate(sun.a+0.01);
}
return vec4<f32>(linear*p.settings.y*p.settings.z,1.0);
}
+2 -2
View File
@@ -1,12 +1,12 @@
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
@group(0) @binding(2) var<uniform> enabled: vec4<f32>;
@group(0) @binding(2) var<storage, read> exposure: vec4<f32>;
@fragment
fn fragment(i: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let c = textureSample(source,source_sampler,i.uv);
let xe = max(c.rgb,vec3<f32>(0.0));
let xe = max(c.rgb*exposure.x/2.5,vec3<f32>(0.0));
let t = saturate(1.0-xe);
let tm = max(xe*0.25+0.75,vec3<f32>(1.0))-t*t;
let gamma = saturate(sqrt(max(tm*0.5,vec3<f32>(0.0)))*1.41);
+148 -13
View File
@@ -1,8 +1,9 @@
#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame}
#import bevy_pbr::shadows::fetch_directional_shadow
struct WorldParams {
mode: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>,
mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@@ -21,11 +22,22 @@ struct WorldParams {
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
struct FrameState { shadow: vec4<f32>, clock: vec4<f32>, pca: array<vec4<f32>, 7> }
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> frame_state: FrameState;
// Textures and base UVs both have V flipped by the exporter. Scale in the
// authored coordinate system, then return to the flipped texture rows.
fn scaled_uv(uv: vec2<f32>, scale: f32) -> vec2<f32> {
return vec2<f32>(uv.x*scale, 1.0-(1.0-uv.y)*scale);
}
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
let fam = u32(p.mode.x);
let flags = u32(p.mode.y);
let a = textureSample(diffuse, diffuse_sampler, i.uv);
var diffuse_uv=i.uv;
if fam==14u { diffuse_uv+=fract(frame_state.clock.x*p.water[1].xy*vec2<f32>(1.0,-1.0)); }
let a = textureSample(diffuse, diffuse_sampler, diffuse_uv);
let lm = textureSampleLevel(lightmap, lm_sampler, i.uv_b, 0.0).rgb;
// Sample before alpha rejection: implicit derivatives must be uniform.
var nm = vec3<f32>(0.5,0.5,1.0);
@@ -35,13 +47,13 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
var masks = vec3<f32>(0.0);
if (flags & 1u) != 0u && (fam <= 6u || fam == 13u) { nm = textureSample(normal_map,normal_sampler,i.uv).rgb; }
if (flags & 2u) != 0u && fam != 2u {
detail = textureSample(detail_map,detail_sampler,i.uv*p.surface.z).rg;
detail = textureSample(detail_map,detail_sampler,scaled_uv(i.uv,p.surface.z)).rg;
}
if fam == 5u || fam == 6u || fam == 13u {
// The reference folds the reflective material's constant detail texel.
if (flags & 128u) != 0u { detail = textureLoad(detail_map,vec2<i32>(0),0).rg; }
}
if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,i.uv*p.surface.x).rgb; }
if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,scaled_uv(i.uv,p.surface.x)).rgb; }
if (flags & 8u) != 0u && (fam == 3u || fam == 4u) { art = textureSample(decal_map,decal_sampler,i.color.xy); }
if (flags & 16u) != 0u { masks = textureSample(specular_map,specular_sampler,i.uv).rgb; }
var wn = normalize(i.world_normal);
@@ -64,14 +76,136 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
#endif
let rpos = i.world_position.xyz - frame::view.world_position;
let vd = -normalize(rpos);
// Static reference axis for the tangent-space sign terms. This is not
// an additive light or a shadow source. Native frame direction remains
// an extraction gap; preserve the adapter's prior direction for now.
let sun = normalize(vec3<f32>(4.0,7.0,4.0));
// Authored render-location direction for the tangent-space sign terms.
// This does not add directional light energy or a shadow source.
let sun = p.sun_direction.xyz;
var d = a.rgb*a.rgb;
var alpha = 1.0;
var lin = vec3<f32>(0.0);
if fam == 9u || fam == 10u {
var baked = lm*lm;
if frame_state.shadow.w>0.0 && (fam<=8u || fam==13u) {
let view_z=(frame::view.view_from_world*i.world_position).z;
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
// The lightmapped receiver source contains only player/board casters.
if (frame::lights.directional_lights[light_id].flags & 5u)==5u {
let visibility=fetch_directional_shadow(light_id,i.world_position,wn,view_z);
baked=min(baked,vec3<f32>(visibility)+frame_state.shadow.rgb);
break;
}
}
}
if fam==14u {
lin=d*p.water[0].y;
} else if fam==32u {
lin=d*p.water[0].x;
alpha=saturate((i.world_position.y-p.water[0].z)/max(p.water[0].y-p.water[0].z,1e-4));
} else if fam==31u {
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
let uv=raw_uv*p.water[1].w;
let sample_uv=vec2<f32>(uv.x,1.0-uv.y);
let c0=textureSample(normal_map,normal_sampler,sample_uv)*2.0-1.0;
let c1=textureSample(detail_map,detail_sampler,sample_uv)*2.0-1.0;
let pca=(vec3<f32>(dot(c0,frame_state.pca[1])+dot(c1,frame_state.pca[2]),
dot(c0,frame_state.pca[3])+dot(c1,frame_state.pca[4]),
dot(c0,frame_state.pca[5])+dot(c1,frame_state.pca[6]))+frame_state.pca[0].xyz)*2.0-1.0;
var overlay=1.0;
if (flags & 4u)!=0u {
let uv_overlay=raw_uv*p.surface.x;
overlay=textureSample(macro_map,macro_sampler,vec2<f32>(uv_overlay.x,1.0-uv_overlay.y)).r;
}
let tonedown=2.0*p.water[1].z*saturate(dot(c1,frame_state.pca[6])+overlay);
let nt=normalize(max(abs(normalize(mix(vec3<f32>(0.0,0.0,1.0),pca,tonedown))),vec3<f32>(0.001)));
let n=nt.xzy;
let rv=vd-2.0*n*dot(vd,n);
var cube=vec3<f32>(0.0);
if (flags & 64u)!=0u { cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb; }
let olm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*nt.xy*vec2<f32>(1.0,-1.0),0.0).rgb;
let fres=0.25+0.75*pow(1.0-abs(dot(n,vd)),5.0);
let u=normalize(vec3<f32>(-n.z,0.0,n.x));
let v=cross(n,u);
let ax=p.water[2].z/p.water[2].x;
let ay=p.water[2].z/p.water[2].y;
let h=normalize(sun+vd);
let eu=dot(h,u)/ax;
let ev=dot(h,v)/ay;
let den=sqrt(abs(dot(n,sun)*dot(n,vd)))*12.566371*ax*ay;
let ward=exp(-2.0*(eu*eu+ev*ev)/(1.0+dot(h,n)))/max(den,1e-6);
lin=(cube*olm*olm*fres+ward*p.water[0].rgb)*p.water[1].y;
alpha=1.0;
} else if fam==30u || fam==33u {
let t=frame_state.clock.x;
// Convert to original UVs for scale/scroll, then back to flipped rows.
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
let uv_scale=select(1.0,p.water[3].x,fam==33u);
let uv1=raw_uv*p.water[2].xy*uv_scale+p.water[1].xy*t;
let uv2=raw_uv*p.water[2].zw*uv_scale+p.water[1].zw*t;
let n1=textureSample(normal_map,normal_sampler,vec2<f32>(uv1.x,1.0-uv1.y));
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(uv2.x,1.0-uv2.y));
var vn=normalize((2.0*n1.rgb+2.0*n2.rgb-2.0)*p.water[0].xzw);
var water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn);
var sample_uv=i.uv;
if fam==33u {
let c1=textureSample(detail_map,detail_sampler,vec2<f32>(uv1.x,1.0-uv1.y))*2.0-1.0;
let c2=textureSample(detail_map,detail_sampler,vec2<f32>(uv2.x,1.0-uv2.y))*2.0-1.0;
let a1=n1*2.0-1.0;
let a2=n2*2.0-1.0;
// water_defaultPS instructions 22..54: the native mean is XYZ,
// weights are R/G/B pairs. FrameState stores the ocean's R/B/G
// arrangement; recover those original registers here.
let mean=frame_state.pca[0].xzy;
let pca1=vec3<f32>(dot(a1,frame_state.pca[1])+dot(c1,frame_state.pca[2])+mean.x,
dot(a1,frame_state.pca[5])+dot(c1,frame_state.pca[6])+mean.y,
dot(a1,frame_state.pca[3])+dot(c1,frame_state.pca[4])+mean.z);
let pca2=vec3<f32>(dot(a2,frame_state.pca[1])+dot(c2,frame_state.pca[2])+mean.x,
dot(a2,frame_state.pca[5])+dot(c2,frame_state.pca[6])+mean.y,
dot(a2,frame_state.pca[3])+dot(c2,frame_state.pca[4])+mean.z);
let first=vec3<f32>((pca2.x*2.0-1.0)*p.water[0].z,
1.0+2.0*(pca2.y-1.0)*p.water[0].z,(pca2.z*2.0-1.0)*p.water[0].z);
let second=vec3<f32>((pca1.x*2.0-1.0)*p.water[0].w,
1.0+2.0*(pca1.y-1.0)*p.water[0].w,(pca1.z*2.0-1.0)*p.water[0].w);
water_n=first*inverseSqrt(max(dot(first,first),1e-12));
let refract=second*inverseSqrt(max(dot(second,second),1e-12));
sample_uv+=0.02*refract.xz*vec2<f32>(1.0,-1.0);
d=textureSample(diffuse,diffuse_sampler,sample_uv).rgb;
d*=d;
vn=water_n;
}
let water_lm_uv=i.uv_b+0.01*vn.xz*vec2<f32>(1.0,-1.0);
var wlm=textureSampleLevel(lightmap,lm_sampler,water_lm_uv,0.0).rgb;
if fam==33u {
// Native tf3 fetches at all four half-texel corners, averages,
// then squares. Squaring each tap would change baked lighting.
let texel=0.5/vec2<f32>(textureDimensions(lightmap));
wlm=(textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel,0.0).rgb
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv-texel,0.0).rgb
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2<f32>(-1.0,1.0),0.0).rgb
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2<f32>(1.0,-1.0),0.0).rgb)*0.25;
}
var lml=wlm*wlm;
if frame_state.shadow.w>0.0 {
let vz=(frame::view.view_from_world*i.world_position).z;
for(var id=0u;id<frame::lights.n_directional_lights;id+=1u) {
if (frame::lights.directional_lights[id].flags & 5u)==5u {
let floor=select(frame_state.shadow.rgb,vec3<f32>(0.09,0.13,0.05),fam==33u);
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+floor); break;
}
}
}
let kd=dot(water_n,vec3<f32>(0.58*sign(sun.x),0.62*sign(sun.y),0.39))*2.39562;
var wm=vec2<f32>(0.0);
if (flags & 16u)!=0u { wm=saturate(textureSample(specular_map,specular_sampler,sample_uv).xz-p.water[3].y); }
let reflected_light=2.0*water_n*dot(water_n,sun)-sun;
let ks=pow(max(saturate(dot(vd,reflected_light)),1e-6),p.water[3].z);
var spec=ks*wm.x*vec3<f32>(2.1,1.8,1.5)*saturate(lml.g-0.1);
if (flags & 64u)!=0u {
let rv=vd-2.0*water_n*dot(vd,water_n);
let cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb;
let lum=0.3*saturate(4.0*wm.y-2.6);
spec+=cube*(lml.g+lum*(1.0-lml.g))*wm.y*1.5;
}
lin=(lml*kd*d+spec)*p.water[0].y;
alpha=max(spec.g,p.water[3].w);
} else if fam == 9u || fam == 10u {
lin = d * max(lm*lm,vec3<f32>(p.family.y)) * p.family.x;
if fam == 9u { lin *= p.family.z; }
alpha = a.a;
@@ -79,8 +213,8 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
lin = d;
if fam == 11u { lin *= p.family.w; }
} else {
if (fam == 3u || fam == 4u) && (flags & 8u) != 0u { d = mix(d,art.rgb*art.rgb,art.a); }
if (flags & 4u) != 0u && fam < 13u { d *= saturate((overlay_sample-0.5)*p.surface.y+0.5); }
if (fam == 3u || fam == 4u) && (flags & 8u) != 0u && (flags & 512u) == 0u { d = mix(d,art.rgb*art.rgb,art.a*p.decal.x); }
if (flags & 4u) != 0u && fam < 13u && (flags & 256u) == 0u { d *= saturate((overlay_sample-0.5)*p.surface.y+0.5); }
var kd = 0.93429;
if (fam <= 6u || fam == 13u) && (flags & 1u) != 0u {
var dxy = vec2<f32>(0.5);
@@ -103,7 +237,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
wn = normalize(raw.x*tt+raw.y*bb+wn*max(raw.z,0.05));
kd = (vnd.x*0.58*sign(dot(kt,sun))+vnd.y*0.62*sign(dot(kb,sun))+vnd.z*0.39)*2.39562;
}
var lml = lm*lm;
var lml = baked;
lin = lml*kd*d;
if fam == 13u { lin = lml*d*a.a; }
if (flags & 16u) != 0u {
@@ -131,6 +265,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
var xe = max((lin*fog_a+p.fog_color.rgb*f)*p.mode.w,vec3<f32>(0.0));
// Reduced curve is the full curve with the linear input capped at one.
if fam == 8u { xe = min(xe,vec3<f32>(1.0)); }
if p.foliage_debug.w != 0.0 { return vec4<f32>(p.foliage_debug.rgb, 1.0); }
if a.a < p.mode.z { discard; }
return vec4<f32>(xe,alpha);
}
+6 -6
View File
@@ -395,8 +395,7 @@ fn material_ids(
m.alpha_mode,
m.alpha_cutoff.to_bits(),
];
let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse)
.filter(|d| d.supported());
let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse);
*unique.entry((key, retail)).or_insert(i)
})
.collect()
@@ -427,6 +426,7 @@ pub(crate) fn spawn(
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
retail_materials: &mut impl crate::map_render::AssetSink<crate::retail_render::RetailWorldMaterial>,
images: &mut impl crate::map_render::AssetSink<Image>,
tuning: &crate::retail_render::MaterialTuning,
) {
// Texture roles have different transfer functions even when sharing a record.
let texture_ids = render_texture_ids(&map.textures);
@@ -481,7 +481,7 @@ pub(crate) fn spawn(
format,
RenderAssetUsages::RENDER_WORLD,
);
if role == 3 || cube {
if role == 3 || role == 6 || cube {
let (bytes, levels) = crate::retail_render::mip_chain(&source.rgba, source.width, height, layers);
image.data = Some(bytes);
image.texture_descriptor.mip_level_count = levels;
@@ -492,7 +492,7 @@ pub(crate) fn spawn(
});
}
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
if role != 1 && role != 4 && !cube {
if role != 1 && role != 4 && role != 6 && !cube {
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
sampler.address_mode_v = bevy::image::ImageAddressMode::Repeat;
}
@@ -574,8 +574,8 @@ pub(crate) fn spawn(
}
}
if let Some(definition) = m.retail_definition.as_deref()
.and_then(crate::retail_render::Definition::parse).filter(|d| d.supported()) {
let material = definition.build(m, &mut texture);
.and_then(crate::retail_render::Definition::parse).filter(|d| d.supported(tuning)) {
let material = definition.build(m, tuning, &mut texture);
let material = retail_materials.add(material);
commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default()));
continue;
+147
View File
@@ -0,0 +1,147 @@
//! Authored FE travel destinations; private content is produced by the extractor.
use std::path::Path;
use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub(crate) struct Destination {
pub id: String,
pub name: String,
pub map: String,
pub matrix: Option<[[f32; 4]; 4]>,
#[serde(default)]
pub unavailable_reason: Option<String>,
}
#[derive(Deserialize)]
struct Catalog { version: u32, destinations: Vec<Destination> }
pub(crate) fn load(assets: &Path) -> Result<Vec<Destination>, String> {
let path = assets.join("private/teleports.json");
let bytes = std::fs::read(&path).map_err(|e| format!("Teleport locations: {e}"))?;
let catalog: Catalog = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if catalog.version != 1 { return Err("Unsupported teleport catalog version".into()); }
let mut ids = std::collections::HashSet::new();
for d in &catalog.destinations {
if d.name.is_empty() || d.id.is_empty() || !ids.insert(&d.id)
|| d.map.is_empty() || d.map.contains(['/', '\\', ':']) || matches!(d.map.as_str(), "." | "..") {
return Err("Invalid teleport destination identity".into());
}
if let Some(m) = d.matrix {
if !valid_matrix(m) { return Err(format!("Invalid teleport transform: {}", d.name)); }
}
}
Ok(catalog.destinations)
}
fn valid_matrix(m: [[f32; 4]; 4]) -> bool {
m.iter().flatten().all(|v| v.is_finite())
&& (0..3).all(|i| m[i][3].abs() < 1e-5)
&& (m[3][3] - 1.).abs() < 1e-5
&& m[2][0] * m[2][0] + m[2][2] * m[2][2] > 1e-6
}
pub(crate) fn same_map(path: &Path, name: &str) -> bool {
path.file_stem().is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_invalid_destinations_and_preserves_authored_heading() {
let mut m = skate_core::physics::skeleton_animation_record::IDENTITY;
m[2] = [1., 0., 0., 0.];
m[3] = [350.5, 140.2, -725.3, 1.];
assert!(valid_matrix(m));
m[3][1] = f32::NAN;
assert!(!valid_matrix(m));
assert!(same_map(Path::new("maps/DownTown.skate"), "Downtown"));
assert!(!same_map(Path::new("maps/MegaPark.skate"), "University"));
}
}
use bevy::prelude::*;
#[derive(Resource, Default)]
pub(crate) struct Travel {
pub open: bool,
pub closed_this_frame: bool,
shown: bool,
rows: Vec<Destination>,
selected: usize,
generation: Option<u64>,
}
#[derive(Component)] struct TravelRoot;
#[derive(Component)] struct TravelRow(usize);
#[derive(Component)] struct TravelScroll;
pub(crate) fn install(app: &mut App) {
app.init_resource::<Travel>().add_systems(PreUpdate,
interact.after(crate::customiser::navigation).before(crate::graphics_menu::MenuInput));
}
fn interact(
mut commands: Commands, mut travel: ResMut<Travel>,
config: Res<crate::config::Config>, map: Res<crate::map_transition::CurrentMap>,
keys: Res<ButtonInput<KeyCode>>, nav: Res<crate::customiser::Navigation>,
mut menu: ResMut<crate::graphics_menu::Menu>, mut skater: ResMut<crate::physics::SkaterRuntime>,
roots: Query<Entity, With<TravelRoot>>,
mut colors: Query<(&TravelRow, &mut BackgroundColor)>,
buttons: Query<(&Interaction, &TravelRow), Changed<Interaction>>,
mut scroll: Query<&mut ScrollPosition, With<TravelScroll>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
) {
travel.closed_this_frame = false;
let wheel_delta: f32 = wheel.read().map(|e| e.y * if e.unit == bevy::input::mouse::MouseScrollUnit::Line { 36. } else { 1. }).sum();
if travel.generation != Some(map.generation) {
travel.generation = Some(map.generation); travel.open = false; travel.shown = false;
for e in &roots { commands.entity(e).despawn(); }
travel.rows = load(&config.asset_root).unwrap_or_else(|e| { warn!("Travel destinations: {e}"); vec![] });
travel.rows.retain(|d| d.matrix.is_some() && map.path.as_ref().is_some_and(|p| same_map(p, &d.map)));
if let Some(id) = &config.teleport {
if map.generation == 0 {
if let Some(m) = travel.rows.iter().find(|d| &d.id == id).and_then(|d| d.matrix) { let _ = skater.travel_to(m); }
}
}
}
if travel.open && !travel.shown {
travel.selected = 0; travel.shown = true;
commands.spawn((TravelRoot, GlobalZIndex(11), Node { position_type:PositionType::Absolute, width:percent(100),height:percent(100),align_items:AlignItems::Center,justify_content:JustifyContent::Center,..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.95))))
.with_children(|root| {
root.spawn(Node { width:px(680),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,..default() }).with_children(|panel| {
panel.spawn((Text::new("TELEPORT"),TextFont { font_size:28.,..default() }));
panel.spawn((TravelScroll, ScrollPosition::default(), Node {height:px(360),overflow:Overflow::scroll_y(),flex_direction:FlexDirection::Column,..default()})).with_children(|rows| {
for (i,d) in travel.rows.iter().enumerate() {
rows.spawn((Button,TravelRow(i),Node {height:px(36),flex_shrink:0.,padding:UiRect::all(px(6)),..default()},BackgroundColor(Color::srgb(0.08,0.11,0.15))))
.with_child((Text::new(&d.name),TextFont {font_size:18.,..default()}));
}
if travel.rows.is_empty() { rows.spawn(Text::new("No destinations available on this map.")); }
});
panel.spawn((Button,TravelRow(travel.rows.len()),Node {height:px(36),..default()})).with_child(Text::new("Back to pause menu"));
});
});
return;
}
if travel.open {
let count = travel.rows.len()+1;
let up = keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0;
let down = keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0;
if up { travel.selected = (travel.selected+count-1)%count; }
if down { travel.selected = (travel.selected+1)%count; }
for mut p in &mut scroll {
p.y = (p.y-wheel_delta).clamp(0., (travel.rows.len() as f32*36.-360.).max(0.));
if (up || down) && travel.selected < travel.rows.len() {
let y=travel.selected as f32*36.; p.y=p.y.min(y).max(y+36.-360.).max(0.);
}
}
let mut chosen = (keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0).then_some(travel.selected);
for (interaction,row) in &buttons { if *interaction == Interaction::Pressed { chosen=Some(row.0); } }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000|0x10) != 0 { chosen=Some(travel.rows.len()); }
if let Some(i)=chosen {
if let Some(m)=travel.rows.get(i).and_then(|d| d.matrix) {
match skater.travel_to(m) { Ok(())=>menu.open=false, Err(e)=>warn!("Travel: {e}") }
}
travel.closed_this_frame = menu.open;
travel.open=false;
}
}
for (row, mut color) in &mut colors { color.0 = if row.0 == travel.selected { Color::srgb(0.10,0.30,0.34) } else { Color::srgb(0.08,0.11,0.15) }; }
if !travel.open && travel.shown { for e in &roots { commands.entity(e).despawn(); } travel.shown=false; }
}
@@ -27,7 +27,7 @@ fn installed_worlds_prepare_commit_and_retire_without_simulation() {
};
let mut world = World::new();
world.insert_resource(Config { asset_root: root.clone(), verification_capture: None,
map: None, map_path: None, difficulty, check_assets: false, start_paused: false, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
map: None, map_path: None, difficulty, check_assets: false, start_paused: false, teleport: None, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
world.insert_resource(CurrentMap::from_package(None, None));
world.insert_resource(graphs);
world.insert_resource(physics);
+183
View File
@@ -0,0 +1,183 @@
# Character lighting investigation
Status: position-dependent character lighting implemented; user-run visual validation pending.
Observed in the local reference renderer (`skate3_native_scene.cpp`, CaptureCharLighting,
and `scene_char.hlsli`): defaultcharacter/CAC shading consumes a directional key,
nine RGB spherical-harmonic rows, ambient multiplier, exposure, rim colour/direction,
key/rim specular colours and powers, material multiplier, and shadow visibility.
The character material path differs from the baked world-lightmap path. Hair uses
its own wrapped diffuse/ambient/rim response. Skin needs its dedicated specular
mask; opaque diffuse alpha is not a substitute. The player currently uses generic
StandardMaterial, with imported-world directional lighting disabled.
Observed in TU3 generated PPC source:
- 0x827E7280 reads nine Vector3 elements from field F45DFCAB6EC195CD, copies them
into 16-byte rows at manager +16, and retains a default-probe mode byte.
- Collections with class hash 6A2B5946514B8490 have default/dlc/cas/freeskate rows
containing this complete nine-element array. Class 1FFDC8E3ACA07C1F also has it.
- 0x827E7348 computes squared position differences against records at +160 and
copies nine lighting vectors from a selected record. This supports a spatial
lighting lookup, but does not yet establish the complete sampling/blending,
registration, or fallback rule.
- `light_probes` (class 0E902F3F038A483C) contains global directional-light structs,
ambient, and arrays of cSimpleLightData. Do not assume these are the same as
the spatial baked SH samples just because of the class name.
Initial investigation checklist (superseded by the implementation status below):
1. Recover the spatial irradiance resource/registration and sampling path and
resolve default probe selection from world/render state.
2. Extract character frame inputs and per-material parameters through native
references, retaining texture/normal/spec-mask semantics.
3. Add a skinned character material adapter that evaluates native SH, key, rim,
and highlights, updated from character position. Keep world materials and
gameplay/physics unchanged.
4. Supply character-only shadow visibility separately from the world's disabled
additive sun/shadow rendering; establish the native source before choosing
its renderer implementation.
5. Validate in exposed and shaded locations and with skin/clothing/hair. No
game launches are authorized by this investigation; prepare user launchers.
Reference caveat: community symbol labels are hypotheses. In particular the
82E3B548 label must not be treated as a verified prototype. No copied renderer
capture's lighting constants should become a universal outdoor preset.
## Implementation, 2026-09-08
`tools.asset_pipeline.irradiance` extracts RX2 type `0xEB0024` from the district's
other presentation assets. Its section starts with count and relative array
offset; each 160-byte record contains nine float4 SH rows, XYZ at byte 144 and a
runtime link word at 156. The `SHP1` sidecar preserves groups, values and original
world coordinates, converting only endianness. University contains 17,493 samples
in 64 resources. Future map conversions write `<map>.irradiance` beside the map.
TU3 `0x827E6E28` registers these resources into a 41-by-41 spatial grid;
`0x827E6AC8` selects the nearest sample in three dimensions from the XZ
neighborhood and falls back to the default coefficients. `0x827E7348` checks a
six-entry MRU cache with a strict squared-distance threshold of 6.25. Raw binary
constant `0x820BD16C` is 15.0 (query margin); `0x822F8F38` is 6.25. The evidence
is the generated PPC instructions corroborated with resource contents and the
TU3 dump with SHA-256
`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`.
Addresses use the dump's `0x82000000` base.
`retail_irradiance.rs` retains the native nearest-sample/cache behavior. Its
static-world adaptation searches all loaded groups using expanded bounds and an
XZ 15-metre neighborhood. It does not reproduce the original 12-resource
streaming limit or grid-cell boundary quantization. The freeskate collection
supplies the nine fallback rows; live changes of the manager's default mode are
not implemented. This is not trilinear probe interpolation.
`tools.asset_pipeline.character_lighting` resolves each selected model's
AttribulatorMaterialName through VLT inheritance and exports its nine complete
material parameter rows. Skin/face specular textures preserve the original red
channel separately; the GLB's grayscale inverse-roughness conversion is not used
as the native specular mask. Source assets remain private.
`retail_character.rs` replaces the ten named retail GLB materials while retaining
Bevy's skinned vertex path. It updates the SH rows from PlayerRoot's position.
The fragment shader evaluates the native SH basis, authored directional key,
ambient multiplier, rim response, key/rim highlights, normal maps and material
multiplier. Diffuse sampling accounts for the GLB's sRGB texture format before
the retail gamma-two decode. Output remains linear HDR for the existing shared
tone pass. The separate depth shader retains alpha rejection.
A zero-lux directional shadow source provides world/self occlusion to the
character's key and key specular terms. The baked world shader does not sample
this shadow map; remaining StandardMaterials receive zero additive sunlight.
This uses Bevy cascades/filtering/biases, not the console's exact cascade layout
and 9-tap shadow filter. It does not add a skater-shadow receiver to baked world
surfaces. No gameplay or physics code changes are involved.
Hair now uses the native wrapped diffuse/ambient/fresnel response, with the
authored material colours and the existing GLB dye/coverage composition. The
live CAC colour-register derivation and separate second-UV blended strand pass
are not reconstructed; hair keeps the installed cutout mode. This is an explicit
adapter limitation, not a claim of exact hair parity.
Scene exposure remains the existing 2.5 day-capture value shared with the world;
the regional exposure controller, water and fog are still unfinished. Missing
lighting sidecars leave the existing GLB material path active and log the reason.
Validation: 13 synthetic Python extraction/inheritance/GUID tests, two spatial
selection/cache tests, and the windowless world-render adapter test pass. Character
colour, shadow and normal-depth shader variants pass offline Naga composition
against the vendored Bevy 0.18 shader modules. Release compilation passes. The
game/recomp was not launched. User visual checks should cover exposed terrain,
sheltered spots, skin/clothing highlights, hair coverage and body/board shadows.
## Player shadow receiver, 2026-09-08
The baked-world receiver is now connected. Native `scene.hlsl` evaluates
`min(lightmap * lightmap, shadow_visibility + shadow_colour)` before diffuse,
normal/detail lighting and specular. This is a component-wise minimum cap, not
an extra multiplicative darkening pass. The local native reference's caster path
is corroborated by TU3 `0x827EBEF0` (CSM setup), `0x827F82F8` (CSM render) and
the character-family shadow shader names in the executable. The animated mesh
and board are the caster geometry; no ground decal or circular blob is used.
Two zero-lux shadow sources separate the receiver contracts. The existing source
includes world and player casters, and is sampled by the character. The new
source includes only the ten skinned retail player/board pieces on reserved
render layer 31. Camera views include that layer; ordinary world geometry stays
on layer zero. World families 1–8 and 13 sample the player-only source and apply
the recovered minimum clamp. This prevents terrain/building shadows from being
applied a second time over the baked map. The source's lightmapped-diffuse flag
identifies its role in the GPU light table, so entity ordering does not determine
which shadow map the shaders select. Both lights have zero illuminance.
This reproduces the projected-mesh and lightmap-combination method, **not full
console parity**. Shadow map projection/filtering/biases still use Bevy cascades.
The original per-frame c8 shadow-colour controller has not been recovered: the
adapter currently uses the local irradiance probe's constant RGB coefficient as
the ambient shadow floor. At University's starting point that is approximately
(0.071711, 0.095925, 0.127945), read from the source sample, not a global preset.
The floor updates when the selected probe changes. Native caster exclusions,
atlas coverage encoding/blur, shadow colour and pixel matching remain separate
parity work. Unsupported world material families still have no custom receiver.
Offline validation: world, character and character depth shaders pass Naga
composition/validation; a windowless ECS regression test verifies the caster-layer
separation and zero illuminance. Release compilation passes. The prepared user
launcher retains the texture-band repairs and prior character lighting. No game
or recomp was launched, and no physics/animation implementation changed.
## Shadow stability follow-up, 2026-09-08
User testing reported thin/warped limbs in the shadow, heavy lag, a crash, and
abrupt changes in darkness. The previous implementation changed every world
material when nearest-probe selection changed. This both stepped the ambient
floor and invalidated all those material assets. It is a confirmed code defect
and a plausible source of the reported stalls; no contemporaneous crash dump or
Application error record was found, so the crash itself is not diagnosed.
World materials now bind one persistent 16-byte storage buffer. An extracted
resource supplies the shadow floor, and the render queue writes it in place
after asset preparation. Neither the storage asset nor world materials are
mutated during probe changes; existing bind groups remain valid. The floor uses
an exponential 0.35-second transition, with each frame's contribution capped at
50 ms to avoid a jump after a hitch. These are adapter settings, not recovered
native constants. The initial sample is applied directly.
The player-only source now uses one 24-metre cascade instead of four extending
to 100 metres. This removes its internal cascade transitions and three shadow
views. Its receiver normal offset is zero and depth bias is 0.002 metres; the
previous defaults were 1.8 texels and 0.02 metres. World-plus-character self
shadowing retains its original source and bias. The new settings target the
reported silhouette distortion, but visible improvement is not confirmed offline.
A single cascade trades distant coverage for stability; shadows outside its
24-metre camera range are intentionally unavailable. Exact console parity is
still pending the native colour controller and filtering work above.
Offline world/character/depth shader validation and six filtered unit tests pass,
including smooth probe transitions, hitch limiting, caster isolation and cascade
configuration. Release compilation passes. The updated private University
launcher records stdout/stderr, backtraces and the exit code in
`logs/renderer-work/player-shadow-runtime.log`. It has not been executed.
## Later visual pass
See [visual-parity-status.md](visual-parity-status.md) for the subsequent SH
transition smoothing, second-UV blended hair coverage, GPU exposure meter and
remaining shadow/CAC fidelity gaps. Earlier implementation sections are historical.
+126
View File
@@ -0,0 +1,126 @@
# Authored props and grime follow-up
## Targeted decal correction after layer test
The user rechecked the comparison and identified decals as the unwanted layer.
Repeating grime is unchanged. Wear/stain decals now use 35% of their texture's
alpha; source UVs, dimensions, texture RGB and placement are retained. This is
explicit visual tuning based on the user's feedback, not proof of the original
game's intended opacity and not a recovered native shader constant.
Classification uses the material's authored `decal` texture label for grime,
grunge, stain, oil-dirt, drainage and ground-decal entries. Arrows, logos,
scratches and ramp edge paint retain full authored opacity. A regression test
covers this separation. This is not a blanket fade on every decal material.
Clamped decals also now receive a mip chain, independently of lightmaps, which
remain bilinear mip zero. Previously the shared clamp texture role omitted mips
for both, leaving fine decal alpha detail unfiltered at distance. The new decal
role retains clamp addressing while permitting mip filtering. No map re-export
or owned texture modification is needed. GPU visual verification remains for
the user; source and shader checks cannot confirm the preferred visual strength.
## Grime investigation after user retest
The UV-scale correction did not resolve the user's reported visual mismatch.
The current runtime log confirms the pictured Super Ultra Mega Park area is in
the University package. The separately named MegaPark district inspected earlier
contains different stadium geometry; it is not the correct visual test location.
The actual University's MPboards/MPCutStn materials contain both repeating macro
weathering and separate grime-puddle, long-grunge, water-stain and building-stain
decals. Binary material channel GUIDs agree with the exported bindings. Fifteen
sampled ramp/wood meshes in the current private University package match their
source cache positions, base UVs and independent decal UVs exactly after the
writer's V flip. This rules out a missing decal UV channel in those samples; it
does not establish visual parity or prove which layer causes the reported marks.
An opt-in `SKATE_WEATHERING_COMPARE=1` diagnostic enables F8 to cycle authored
layers, repeating grime off, decals off, and both off. The window title and log
identify the selected mode. The comparison launcher fixes exposure at 2.5 so
automatic brightness adaptation cannot counteract each layer change. Normal
launches retain their original rendering. Material uniforms change only on a
keypress; the original texture flags remain intact for restoration.
This is an isolation tool, not another claimed grime fix. The remaining
strength/scale mismatch needs a user-run layer comparison and ideally a matching
original-game view. No arbitrary opacity or UV scale change was applied.
## Hair rollback
Removed the hair-specific changes from 96adff5 at the user's request: coverage
texture bindings/blending, depth coverage, exported secondary UVs, FLOAT2 vendor
decoder correction and its test. The private character package was regenerated
with the previous exporter. Character probe smoothing remains. The other
worktree's hair implementation was not touched or imported.
## Grime coordinates
The map writer flips both texture rows and base UV V. The world shader formerly
sampled macro/detail maps at `flippedUV * scale`. For a scale of 0.3 this gives
`0.3 - 0.3*v`, but the matching texture-row coordinate is `1 - 0.3*v`: a 0.7
texture-period displacement. MegaPark's authored macro scale is approximately
0.3. This shifts stains onto different parts of the ramps and over decal paint.
Macro and detail UVs now scale in the original coordinate system, then flip V.
The authored opacity and native-reference overlay equation are unchanged:
`saturate((macro.rgb - 0.5) * opacity + 0.5)`, multiplied over the linear
diffuse/decal composite. Reference: scene.hlsl in native renderer commit
f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd. No arbitrary global grime reduction
was introduced. Perceived strength still requires an in-game comparison after
the coordinate correction; adaptive exposure also changes overall brightness.
## Dynamic-object initial placement
Observed source: district Sim RX2 `0xEB001D` records have a 32-byte header,
128-byte records, row-vector affine matrix at +0, world bounds at +64,
instance/locator IDs at +96/+104, template ID at +112 and name offset at +124.
Native TU3 `0x825876D0` (community symbol SimManager::SpawnStatic) loads the
translation at +48 and template ID at +112 before the DMO model lookup.
Mapped image SHA256:
F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4.
`worlddmo.big` supplies reusable templates. Their EB000D 160-byte tInstance
records bind template IDs to EB0001 model sections. Model mesh tables reference
EB0023 sections, which reference the vertex declarations. The exporter follows
those indices, never mesh order or material display names. Material texture
GUIDs come from the binary channel bindings: the DMO texture namespace's high
bit is absent from display-name suffixes, so suffix matching loses textures.
The exporter composes model, template and locator matrices in row-vector order;
normals use the inverse transpose. Reflections reverse triangle winding. It
neither snaps objects to terrain nor changes their authored matrices. Structured
JSON alongside each presentation package retains IDs, matrices, source hashes,
offsets and unresolved locators for a future dynamic runtime.
Offline restored placements:
| Map | Placed | Unresolved |
| --- | ---: | ---: |
| University | 479 | 0 |
| DownTown | 786 | 14 |
| Industrial | 162 | 18 |
| BlackBoxPark | 12 | 0 |
| MaloofMoneyCup | 31 | 0 |
| Remaining five maps | 0 | 0 |
All University placements resolve. 478 of 479 transformed render bounds agree
with their authored locator bounds within 5 cm (most within millimetres). One
open dumpster differs by about 14.5 cm; its authored matrix is retained, since
render bounds and assembly/animated bounds need not coincide.
The 32 unresolved Downtown/Industrial locators reference six template IDs absent
from the supplied DMO catalog. The named assets are also absent from parkassets
by those IDs, and district presentation template tables do not define them.
They remain explicit unresolved records; no substitute geometry is invented.
Runtime loads private/native-props presentation supplements alongside the
existing backdrop packages. This restores visible initial placement only:
objects are not yet pushable, droppable or collidable. Their current materials
use the existing PBR fallback, not the native dynamicobject lighting shader.
No static district collision, gameplay or animation logic was changed.
Validation: synthetic record, rotation/scale/normal and invalid-reference tests;
original-data template resolution and placement-bound comparison; offline map
readers and shader composition. Game/recomp was not launched. GPU execution,
appearance, performance and gameplay interaction are not validated here.
+186 -17
View File
@@ -18,10 +18,19 @@ Implemented:
colour and depth passes. Foliage is two-sided.
- Lightmaps remain bilinear, clamped and fixed at mip zero. Repeating world
textures have mipmaps.
- The three major districts use their actual sky geometry and diffuse texture
from `miscload.big`. Texture selection follows material GUIDs through the
RX2 reference table. Sky X/Z follow the camera; Y uses the reference's
documented 165-metre default.
- All ten installed worlds resolve sky geometry/texture paths through their
authored `world` collection inheritance. The parks inherit the default
panorama; Downtown and Industrial override it. Sky X/Z follow the camera;
Y comes from the selected `render_locations` chain, not camera height.
- The sky uses its material's diffuse and specular GUID bindings. The latter
is the 512x16 radial sun gradient: sine-angle lookup, squared RGB divided
by saturated alpha + 0.01, then the material multiplier and scene exposure.
`material_sky/default.m_params[0]` supplies scale 0.75 and multiplier 0.35;
these are decoded inputs, not constants copied from one frame. This follows
`scene.hlsl`'s sky branch and `skate3_native_scene.cpp`'s sky-bank layout.
- The selected render location supplies the normalized sun vector used by
the sky and existing world tangent-sign terms. This adds no directional
light energy or shadow map to imported worlds.
The converter reads scalar material channels at record offset 0x10.
Earlier exports incorrectly read their string pointer and saved empty strings.
@@ -29,22 +38,182 @@ Reconvert maps to obtain those constants and complete reflection cubes.
Old packages still load; missing constants disable the relevant layer and
single-face environment textures are not treated as cubes.
The VLT converter now adds `array` to array fields, retaining the existing
`type` and `data` unchanged. `array.items` contains complete big-endian element
hex strings without padding; `capacity`, `element_size` and `alignment`
describe the source layout. Text arrays additionally retain decoded
`text_items` before the binary bank is discarded. The layout follows
VaultLib's `VLTArrayType`: four big-endian u16 header lanes followed by
individually aligned elements. Alignment in the schema is a base-two exponent.
Skies can be refreshed independently of map/character conversion:
```text
python -m tools.asset_pipeline.sky --game-root OWNED_EXTRACTED_GAME --assets PRIVATE_ASSET_ROOT
```
Use a private staging asset directory for experiments. This reads only the
database pair and sky resources; it does not require an ISO or Blender.
Legacy sky metadata retains its previous multiplier and has no sun gradient.
## Treeline investigation
The missing forest in the supplied University / Super Ultra Mega Park
comparison is not resolved by this change. The camera-relative
`WorldPresEntityOptimesh` anchor from `sub_82792900` is consistent with the sky
placement above, but the decoded University panorama has mountains and city
scenery, not a dense forest layer. Its RX2 contains one 380-triangle dome.
The separate `DIST_MegaPark` archive contains 102 mesh parts and no tree mesh;
it is not a source for additional trees at University's park.
The main University export retains 479 model assets, 8,546 mesh parts and 86
named TreeWall parts. Those named walls are outside the immediate park area;
their absence at that location does not prove geometry is missing. Nearby
proxy geometry demonstrably overlaps the main trees: in cell 350/-750,
451 of 456 proxy long-tree triangles match triangles in the 912-triangle
main tree after quantization to one millimetre, independent of winding.
The five nonmatching triangles are not evidence for an extra forest.
The main tree's diffuse and transparent channels resolve the same DXT5
texture, with nonconstant alpha, and its baked lightmap is retained. The
renderer already uses the retail cutoff in colour/depth and two-sided foliage.
No proxy geometry has been added. `skate3_draw_distance.cpp` documents the
ProxyWorld/full-detail exchange; rendering both whole sets is not a valid
fix. Identifying the specific missing silhouette now requires matched-camera
reference evidence connecting it to a submitted mesh/material or a streaming
collection. The two supplied images have different viewpoints. Native mip
selection and regional/streaming state also remain unverified.
## Remaining differences
This is not a claim of pixel parity. Native frame constants are supplied by
game hooks in the reference; this engine does not yet recover that controller.
Exposure 2.5, material multiplier 1, and tree/proxy values use documented
day-capture defaults. Imported maps have no dynamic directional sun or realtime
sun shadows: their world lighting comes from the baked lightmaps. Normal-map
sign terms retain the adapter's fixed direction until native frame data is
available; this adds no light energy or shadow map. The fog inputs
are wired into the shader but remain disabled without authored frame rows.
The current pass enables authored distance fog, GPU exposure adaptation, flowing
water, scrolling emissive signs, the global reflective water sheets and additional
hair strand coverage. See [visual-parity-status.md](visual-parity-status.md) for
provenance, validation and the distinction between native equations and adapters.
Sky sun-gradient shading, park sky selection, native water, character shaders,
SSAO, SSR, bloom and volumetric lighting are not yet ported. The character and
unsupported world families still use Bevy materials beneath the shared tone
curve. Reflection normals use the reference's analytic world-up construction;
there has been no matched-camera pixel comparison against the recomp.
This is not pixel parity. Native regional environment selection is not ported;
static map selection supplies the fog/sun and the default exposure tuning. The
exposure meter uses portable HDR luminance samples instead of the native resolve
surface. Shadow colour/filtering and CAC hair colour-register composition still
have documented approximations. Ocean PCA wave reconstruction now uses the owned 30-frame table; its native
phase/reset controller and the separate non-flowing water shader remain unported. SSAO, SSR, bloom and volumetric lighting
are also outside the currently implemented renderer. Missing parameters retain
fallback materials; these effects are not silently labelled complete.
The shaders are embedded in the executable. Sky and map payloads are extracted
from the user's game and must never be committed or bundled with releases.
### Treeline investigation and diagnostic (2026-09-08)
The user's mega-park screenshots refer to the bowl/cliffs in University, not
DIST_MegaPark (the separate create-a-park venue). A read-only decode of the installed
University SKATE14 package found 8,546 materials, 1,645,617 render triangles,
1,180 tree-family mesh materials and all 86 TreeWall meshes from the source cache.
TreeWall diffuse texture 0x2c70170a00053a88 retains its RGBA cutout, and the tree
materials retain masked alpha and two-sided flags. A TOC audit of all 479 full
models found no extra vertex or face buffers omitted by the parser.
Proxy comparisons must resolve mesh material GUIDs with
`_bind_material_groups_by_guid`; `mdl_parser.Mesh.name` is in material-table order
and can label proxy terrain as trees. With GUID bindings, the 14 proxy foliage
meshes intersecting x=200..550, z=-850..-600 contain 3,878 distinct triangles;
3,813 match full geometry after unordered-vertex quantization to 1 mm. The small
remainder does not establish absent trees (different triangulation/rounding also
changes these keys). DMO_University and DMO_Global contain movable props, not a
second background forest. These checks do not prove visual parity or a fix.
For a user-run discriminating capture, `SKATE_DEBUG_FOLIAGE=1` renders tree-wall
cards cyan and other tree-family surfaces magenta, opaque and unlit. Geometry,
camera, depth testing and occlusion remain authored. This bypasses texture alpha
in both colour and depth passes without adding proxy geometry. Return to the
reported viewpoint: solid foliage appearing in the missing area implicates the
normal shading/alpha path; an empty area requires checking placement, camera or
source selection. Unset the variable to restore normal rendering. This diagnostic
has been prepared without launching the game.
### Missing global tree-wall backdrop resolved (2026-09-08)
The user-run opaque-foliage diagnostic left the same empty background. The
missing surfaces are in `world/models/DIST_Water_University.rx2`, not in
`DIST_University` or its proxy stream. Despite its name, this global model has
both an ocean plane and a separate tree material: mesh 1 contains 1,572 vertices
and 786 tree-wall triangles. World fields `951898F6C0FA6856` and
`CA5A157A65E75934` select this model and its texture resource. The global
Industrial model likewise contains 716 foliage triangles.
`tools.asset_pipeline.backdrop` follows those world references and exports only
the tree-family presentation meshes into `private/native-backdrops/<map>.skate`.
Mesh-to-material and material-to-texture selection use binary GUIDs: texture
name suffixes differ from the global texture resource GUIDs. Original positions,
indices, UVs, masked alpha, two-sided rendering and baked lightmaps are retained.
At that stage only tree-family meshes were selected. The later visual pass also
retains the authored global reflective water sheets; no synthetic placements are added.
The renderer loads this supplemental package after the district and before sky
setup, through the existing retail mesh/material path. `parse_render_only` is
an explicit data-reader entry point for presentation packages; ordinary playable
map loading still requires collision. Presentation loading rejects collision,
rails, doors, lights, routes and extensions other than WMET. This does not alter
the playable map's collision or gameplay state. Future installation runs prepare
the supplement automatically; existing assets can be updated with:
```powershell
python -m tools.asset_pipeline.backdrop --game-root <owned-game> --assets <assets-root>
```
Static verification: the University supplement contains exactly the source's
786 triangles and matching vertex positions/indices/UVs. Zero of these triangles
match the 86 existing district TreeWall meshes after unordered-vertex 1 mm
quantization. Both University and Industrial supplements pass the runtime data
reader's offline inspector. Synthetic GUID tests and map-reader regression tests
pass. The executable was compiled but not launched; visual confirmation remains
for the user. The normal launcher clears SKATE_DEBUG_FOLIAGE.
For integration with staged map switching, prepare this supplement alongside the
district, before applying sky lighting. Track its entities and assets in the
same map lifetime. The current adapter is in `retail_backdrop.rs`.
### Black texture/lightmap bands (2026-09-08)
The University bark rings and mega-park shark logo expose an exporter defect in
the shared Xbox texture decoder. `XGAddress2DTiledX/Y` returns coordinates in the
padded physical tile. `_untile360` flattened `y * logical_width + x` before
rejecting columns outside the logical width. Padding columns consequently aliased
onto later valid rows, overwriting colour blocks with padding or other tile data.
The NumPy implementation reproduced the scalar bug, including last-write wins.
Both implementations now reject `x >= logical_width` before writing.
The logo's diffuse is `0x2c70170a001d01bb`; its 64x64 DXT1 lightmap is
`0x8b068e5c4f70dd7a`. Re-decoding that lightmap removes the black stripes while
the logo diffuse remains unchanged. Nine lightmaps bound to the spring/fall-tree
bark diffuse textures `0x00007e2903e3870a` and `0x00007dc403e3870a` have the same
corruption. The shared decoder affects colour textures as well: the 64x128 palm
fringe texture `0x00007e3103e3870a` also loses artificial horizontal bands.
This establishes an asset-decoding cause independently of shader lighting.
A separate colour-palette error was corrected in both decoders: DXT3/DXT5 colour
blocks must retain four interpolated colours even when their endpoints are in
ascending order. The old code reused DXT1's three-colour/black branch and merely
made that black opaque. DXT1's transparent palette remains intact. See Microsoft's
[block compression reference](https://learn.microsoft.com/en-us/windows/win32/direct3d10/d3d10-graphics-programming-guide-resources-block-compression)
for the format layouts and separate BC2/BC3 alpha blocks.
The private University repair re-decodes the original RX2 sources and changes
723 of 2,046 texture payloads: 720 DXT1 (logical widths 16/32/64), two DXT5 and
one A8R8G8B8. It preserves the exporter's dedicated B5G6R5 decoder output. Every
original payload was checked against its source cache before replacement;
material definitions and the entire geometry/collision/metadata tail remain
byte-identical. The repaired package passes the offline SKATE reader.
Regression tests cover narrow/wide tile layouts at 2/4/8/16 bytes per unit,
DXT1 transparency, and DXT3/DXT5 reversed colour endpoints with independent alpha,
using both scalar and NumPy paths. All 16 asset-pipeline tests pass. The game was
not launched; visual confirmation remains user-run. Existing exported maps need
their textures regenerated from RX2, since changing the decoder cannot repair
already-baked PNG/RGBA cache files. Fresh conversions use the fix across maps.
Player-shadow stability follow-up: the ambient floor is now a smoothed shared
16-byte GPU buffer, updated without rebuilding world materials. The player-only
source uses one 24 m cascade and reduced receiver bias. Native colour/filter
parity and runtime visual/performance verification remain outstanding; see the
character lighting investigation. The private launcher now captures crash output.
+94
View File
@@ -0,0 +1,94 @@
# Travel menu and still-water follow-up
## Travel
Esc → Teleport lists the original English frontend location names for usable
destinations on the currently loaded map. A continuous list supports mouse-wheel
scrolling, a draggable scrollbar and keyboard selection that scrolls into view.
There are no pages or cross-map destinations in this menu. The installer
now extracts `private/teleports.json` by joining `fe_locations`, the shipped label
and English language tables, and global RX2 locator records. Names are joined by
their label hashes, not string-table order. The special skate wordmark byte is
represented as `skate.` in plain text.
The current owned installation yields 42 frontend destinations, 41 with unique
authored transforms. `Stadium - Monster Park` remains in the catalog but is hidden
from the menu because it is unavailable:
its reference `vert_skp5_01_challengelocator_01` was not found in the global
locators or the inspected mission payload. No substitute position is invented.
Frontend templates and unnamed park-layout aliases are excluded.
Same-map travel queues the existing player-input teleport/reset service and
updates the checkpoint. The `--teleport ID` startup adapter also checks that the
destination belongs to the loaded map; menu travel does not restart the session.
Authored height and orientation are preserved. Runtime arrivals and menu layout
have not been checked in a running game.
Evidence: TU3 `cGlobalFileLocationManager::LoadGlobalLocations` at `0x828A3D50`
loads `data/content/global_locators/*.rx2`. The RX2 `0xEB0009` section contains
128-byte records with the transform at +0, name pointer at +104, tag pointer at
+108 and identifier at +112. The parser validates record and string boundaries.
All 41 resolved positions have collision surfaces beneath their authored X/Z in
the exported maps; vertical offsets range from approximately -0.008 to 4.536 m.
This is an offline placement check, not a gameplay arrival test.
## Rendering
Still-water materials (`water.default`, `water.alpha`, `water.skatepark`) now
have a dedicated family. Retained shader names upgrade older map packages at
load time. The shader implements the decoded two-layer PCA normal reconstruction,
UV scrolling, refraction, four-tap lightmap sampling and literal shadow floor.
Flowing-water materials keep their existing family. Still water requires the
private ocean PCA sidecar; automatic extraction of that sidecar from a fresh
installation remains outstanding, and missing data uses the existing fallback.
The default and alpha pixel shaders have 128 bytes of literal constants before
their instructions. SHA-256 of their instruction streams:
- Default: `B5F39F3080C89A220250E87643C5AD15F4A94B42B2C3DCB6BAF090E036FE3B5A`
- Alpha: `81D9905165B28B03C3623D18887E7E9D9303D3FFDA072993B8C183D24BD3A547`
An offline register evaluator compared 600 normal/refraction cases across all 30
owned PCA frames with the implemented algebra (maximum error 2.22e-16). This
does not establish full pixel-shader or GPU visual parity. Private disassembly
and validation output are under `logs/renderer-work/shader-inspect`.
Exposure extraction now follows each map's render-location reference and its
inherited exposure settings into `private/exposure-profiles.json`. Runtime
selects the active map's profile, with the existing global fallback. The ten
current profiles resolve to the same values, so selection alone need not produce
a visible change.
## Remaining parity work
- Spatial lighting/colour transition volumes and their native controller.
- Exact colour grading: profiles reference `cc_financial.rgb`, which was not
found in the inspected content. The available `cc_petes.rgb` is not evidence
that this different lookup table should be applied.
- Native bloom composition, console shadow-atlas filtering and the per-frame
shadow-colour controller.
- Fresh-install ocean PCA extraction and in-game water/teleport validation.
Movable-object work is deferred at the user's request. Hair is unchanged.
## Checks
Release build, four filtered Rust menu tests, eight Python extraction/environment
tests, and composed world/depth shader validation passed. All ten map packages
passed the offline reader; their geometry/collision tails remain byte-identical
to the installed inputs. No game or recomp was launched. Owned assets and private
inspection artifacts are not committed.
## Integration with the current engine
The Teleport option is a separate scrollable pause-menu panel alongside character
customisation, multiplayer and updates. Its list refreshes after in-process map
changes and contains only destinations in the active map. Travel uses the existing
manual teleport reply so it does not replace automatic bail checkpoints.
Scenery supplements and authored skies are prepared through the asynchronous map
asset staging path, then retired with the map. Character shadow casters use layer
28, separate from the scoring HUD on layer 31. Map changes reload character lighting
and exposure. These integration changes require manual visual checks across a map
switch, a destination teleport and the scoring/character menus.
+128
View File
@@ -0,0 +1,128 @@
# Visual parity status – 2026-09-08
Follow-up: the hair changes below were rolled back at the user's request.
See [dynamic-props-and-grime.md](dynamic-props-and-grime.md) for the current
hair rollback, grime coordinate correction and authored prop placement status.
Implemented and compiled offline; game/recomp not launched. User-run checks are
still needed for the previously reported crash, shadow silhouette and performance.
No physics, collision, gameplay or animation logic was changed.
## Authored distance fog
**Observed:** TU3 `0x828012D0` loads the fog collection at manager +180. Schema
`material_fog` offsets are colour=0, power=16, near=20, max=24, far=28. The routine
writes ramp `(1/(far-near), -near/(far-near), power)` and colour
`(colour.rgb*max, -max)`. The negative-one vector at `0x830BD4A0` is initialized by
`0x82F825F0` from float -1 at `0x8216DEE0`; its zero bytes in a static dump must
not be interpreted as the initialized value. Colour is not squared here.
The reference world shader applies `f=pow(saturate(distance*scale+bias),power)`
and `linear*(1-max*f)+colour.rgb*max*f`, before exposure. The exporter retains
both the authored inputs and these derived rows. All ten private sky metadata
files are refreshed. Regional transitions remain unimplemented.
## Exposure
**Observed:** `0x827F0D00` reads the GPU measurement, scales the weighted mean by
2.515 (`0x821A01E8`), and evaluates
`clamp(previous*(1 + damping*(target-measured)), min, max)`.
The tuning schema independently confirms offsets 120/124/128/132 for
`auto_exposure_target_luminance/min/max/damping`. The owned default collection
contains target .25, min .75, max 2.5, damping .5. `0x827F0B78` uses squared
`1-abs(x*y)` centre weighting. The comparison renderer normally pins exposure at
zone max because its resolve readbacks are disabled; that is not console adaptation.
**Adapter:** one 256-thread compute group meters a 16x16 bilinear sample grid of
linear HDR using Rec.709 luminance, native centre weighting and the native update
formula. It uses a 30 Hz time basis and caps hitch contribution at 50 ms. This is
not the native packed, resolved measurement surface or regional tuning selection.
The compute result stays on the GPU; the existing final tone pass consumes it.
There is no synchronous CPU readback and no per-material exposure update.
`SKATE_FIXED_EXPOSURE=1` retains 2.5 for comparison. Missing metadata also retains
fixed exposure. No claim is made that this meter's measured values match console.
## Water and other world materials
The material adapter now retains its shader key and loads authored VLT parameter
arrays from `private/render-parameters.json`. Additional supported families:
- 30, `water.flowing*`: two time-scrolled normal taps, mapped normal, refracted
lightmap coordinates, player-shadow minimum clamp, sun specular, cube reflection
and native alpha floor. Follows reference `scene_water.hlsli`'s flowing shader.
Texture row/UV flips are accounted for in scale, scroll and refraction offsets.
- 14, `incandescent.backlituvscroll`: authored `uAnimationSpeed/vAnimationSpeed`,
squared diffuse and material multiplier, followed by fog/exposure.
- 32, `ocean.reflection`: shader support for squared horizon colour and the authored
height fade. This is not the animated ocean-surface shader (family 31).
Global model export also includes authored `environment.reflective_simple` water
sheets previously excluded by the foliage-only filter. Their original GUID-bound
textures and mesh positions are retained in render-only supplements. These have
no collision and never enter physics. Three maps now have global supplements.
**Ocean PCA follow-up:** `0x827905B0` initializes a 30-frame table with means at
`0x830118D8` (3 floats/frame) and weights at `0x83011A40` (24 floats/frame).
`0x82790858` selects frames at 1/30-second intervals, scales mean/weights by
1/255, and publishes the seven rows. The reference shader binding swizzles mean
X/Z/Y and weight pairs R/B/G. The build-checked `ocean_pca` extractor preserves
those tables in a private sidecar. Family 31 now reconstructs normals from both
PCA component textures and these animated rows, then evaluates Fresnel cube
reflection, squared lightmap and Ward sun specular from the native shader.
The adapter selects the authored frame by elapsed time; native phase/reset and
hitch advancement are not reproduced. It requires the supported owned mapped
image for extraction; missing/invalid PCA metadata retains the prior fallback.
It does not invent procedural waves.
**Still unported:** the distinct non-flowing `water.default`/`water.alpha` shader.
The local reference also uses an empirical fallback for this family, so its exact
shader contract needs separate recovery.
## Character and shadows
The native nearest-probe selection/cache remains intact. Displayed SH coefficients
now ease between samples using the same bounded .35-second adapter transition as
the shadow floor. All ten models retain their existing skinning and poses.
The GLB exporter now preserves the hair's raw second UV set. RX2 declaration
`0x002C23A5` is format 37 (big-endian float2), confirmed against the native
vertex decoder. The old skeleton parser incorrectly read it as four normalized
shorts. That decoder is corrected, and glTF accessor shape checks now reject
four-component data declared as VEC2. The character
sidecar references the original coverage texture; the shader samples its red
channel on that second UV, scales it by `m_params[6].x` (CAC hair c15.x), and uses
blended strand coverage. Its depth path retains strand holes with the world
30/255 threshold, an adapter choice. Existing dye/rim composition remains an
approximation: live CAC colour-register derivation is still outstanding.
The previous player-only shadow source and smooth shared shadow floor remain.
Its shared storage buffer is now 144 bytes to carry animation time and ocean PCA; neither
world materials nor their bind groups are rebuilt as these values change. Exact
console atlas/filtering and the per-frame shadow-colour controller remain pending.
## Existing maps
Freshly decoded RX2 caches refreshed 2,019 of 7,204 texture payloads across ten
private maps. Each map passes the offline reader, and its entire geometry,
collision and metadata tail is byte-identical to the installed input. Material
prefixes are preserved. Original installed maps are untouched. New refresh tooling
publishes atomically and refuses in-place input overwrite.
## Evidence and validation
PPC source and raw dump identity: base `0x82000000`, SHA-256
`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`.
Local native renderer reference commit
`f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd`, shaders `scene.hlsl`,
`scene_water.hlsli`, `scene_char.hlsli`, plus `skate3_native_render.cpp`.
Private evidence lives under `logs/renderer-work`: `exposure-evaluator.txt`,
`sub_828012D0-fog-candidate.txt`, per-map texture reports and
`visual-refresh-validation.json`. Owned data is not committed.
Offline checks cover shader composition, exporter fog boundaries, invalid refresh
rollback and existing asset regressions, plus the windowless Rust rendering tests.
These do not validate GPU execution, visual parity, frame time or the prior crash.
Final offline validation: 20 Python regression tests, six filtered Rust tests,
three render batching tests, six composed shaders and the release build passed.
All ten refreshed maps passed the offline reader and preserved geometry tails.
+138
View File
@@ -0,0 +1,138 @@
"""Retain foliage in authored global world models outside district streams."""
import json
import hashlib
import struct
import sys
import tempfile
from pathlib import Path
from .environment import Collections, field, key_hash
from .map_writer import write
from .sky import _texture
from tools.owned_game.big import BigArchive
def texture_groups(raw, table, channels):
"""Read binary channel GUIDs; display-name suffixes are not resource IDs."""
groups = []
for section in table.entries:
if section.type_id != 0x00eb0005:
continue
o = section.f0
h = struct.unpack_from('>8I', raw, o)
if not h[1] or (h[4] - h[3]) != h[1] * 32:
raise ValueError('Unexpected global model material layout')
for j in range(h[1]):
v = struct.unpack_from('>8I', raw, o+h[3]+j*32)
at = o+v[0]
kind = raw[at:raw.index(b'\0', at)].decode('ascii')
if kind == 'Name':
groups.append({})
if kind in channels:
groups[-1][kind] = (v[4] << 32) | v[5]
return groups
def convert(game_root, assets, converted):
vendor = Path(__file__).resolve().parents[1] / 'vendor'
sys.path.insert(0, str(vendor / 'utt'))
sys.path.insert(0, str(vendor / 'university/tools/vanilla_map_extraction/tools'))
import numpy as np
import mdl_parser
import rx2_parser
import prepare_hawaiian_dream as prep
from retail_lightmap_uv import decode_lightmap_uvs
from retail_texture_decode import B5G6R5_FORMAT_ID, decode_b5g6r5
archive = BigArchive(game_root / 'data/big/miscload.big')
entries = {e.path: e for e in archive.entries}
collections = Collections(converted)
output = assets / 'private/native-backdrops'
output.mkdir(parents=True, exist_ok=True)
available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')}
count = 0
for (cls, _), row in collections.rows.items():
if cls != key_hash('world'):
continue
own_stream = next((v['data'] for k, v in row['fields'].items()
if key_hash(k) == key_hash('WorldStream')), None)
if own_stream not in available:
continue
fields, _ = collections.resolve('world', row['key'])
# These global presentation resources are separate from WorldStream.
if key_hash('Hash_951898F6C0FA6856') not in fields:
continue
model_name = field(fields, 'Hash_951898F6C0FA6856')
if not model_name:
continue
texture_name = field(fields, 'Hash_CA5A157A65E75934')
raw = archive.read(entries['data/content/' + model_name + '.rx2'])
model = mdl_parser.parse_rx2(raw)
groups, bindings = prep._bind_material_groups_by_guid(
raw, prep._group_material_parameters(model.materials), len(model.meshes),
allow_import_order_fallback=False)
selected = [(i, prep._material_metadata(groups, i)) for i in range(len(model.meshes))]
selected = [(i, m) for i, m in selected if m['shader_name'] in ('tree.default', 'animated.tree', 'ocean.reflection', 'environment.reflective_simple')]
if not selected:
continue
textures = rx2_parser.parse_rx2(archive.read(entries['data/content/' + texture_name + '.rx2']))
table = rx2_parser.RX2File(raw)
table.parse()
channel_groups = texture_groups(raw, table, prep.RETAIL_TEXTURE_CHANNELS)
with tempfile.TemporaryDirectory(prefix='skate-backdrop-') as temporary:
root = Path(temporary)
arrays, meshes, exported_textures = {}, [], {}
for i, material in selected:
mesh = model.meshes[i]
binding = bindings[i]
meshes.append(dict(material, index=i, name=groups[i]['Name'][0],
retail_material_guid=f"0x{binding['material_guid']:016X}",
retail_material_handle=f"0x{binding['material_handle']:08X}",
retail_material_group_index=binding['group_index'], source_offsets=mesh.source_offsets))
arrays[f'vertices_{i}'] = mesh.vertices
arrays[f'faces_{i}'] = mesh.faces
arrays[f'uvs_{i}'] = mesh.uvs
arrays[f'normals_{i}'] = mesh.normals
lm = decode_lightmap_uvs(raw, vertex_buffer_offset=mesh.source_offsets['vertex_buffer'],
vertex_count=mesh.vertex_count, vertex_stride=mesh.vertex_stride, attributes=mesh.attributes)
if lm is not None:
arrays[f'lightmap_uvs_{i}'] = lm.values
for role, name in material['retail_texture_ids'].items():
if name in exported_textures:
continue
texture = _texture(textures, channel_groups[binding['group_index']][role])
rgba = texture.rgba
if texture.fmt_id == B5G6R5_FORMAT_ID:
rgba = decode_b5g6r5(textures.data[texture.data_offset:texture.data_offset+texture.buffer_size],
texture.width, texture.height)
path = name + '.rgba'
(root/path).write_bytes(rgba)
exported_textures[name] = dict(width=texture.width, height=texture.height, rgba=path)
np.savez(root/'model.npz', **arrays)
name = own_stream.removeprefix('DIST_')
manifest = dict(map_name=name, district_name=own_stream, models=[dict(
asset_id='0x'+hashlib.sha256(raw).hexdigest()[:16], npz='model.npz', meshes=meshes)],
textures=exported_textures, normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[],
source_model=model_name, source_textures=texture_name, source_sha256=hashlib.sha256(raw).hexdigest())
path = root/'manifest.json'
path.write_text(json.dumps(manifest))
write(path, output/(name+'.skate'), None, render_only=True)
count += 1
return count
if __name__ == '__main__':
import argparse
from .vlt import convert as convert_vlt
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--game-root', type=Path, required=True)
parser.add_argument('--assets', type=Path, required=True)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='skate-backdrop-db-') as work:
database = BigArchive(args.game_root/'data/big/db.big')
needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'}
database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work))
stem = Path(work)/'data/db'
names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines()
converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', names)
print('Prepared', convert(args.game_root, args.assets, converted), 'authored foliage backdrops')
@@ -0,0 +1,55 @@
"""Export authored character shader parameters and default SH coefficients."""
import json
import struct
import sys
from PIL import Image
from pathlib import Path
from .environment import Collections, key_hash
def convert(models, private, converted):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
import mdl_parser
collections = Collections(converted)
result = {'materials': {}}
recipe = json.loads((Path(__file__).resolve().parents[1] / 'default_skater_retail_manifest.json').read_text())
components = {c['slot']: c for c in recipe['components']}
for slot in sorted(models.iterdir()):
if not slot.is_dir():
continue
files = list(slot.glob('*.rx2'))
if len(files) != 1:
raise ValueError('Ambiguous character lighting model: '+slot.name)
model = mdl_parser.parse_rx2(files[0].read_bytes())
shaders = {p.value for p in model.materials if p.kind == 'AttribulatorMaterialName'}
if len(shaders) != 1:
raise ValueError('Ambiguous character shader: '+slot.name)
shader = shaders.pop()
family, key = shader.split('.', 1)
fields, _ = collections.resolve('material_'+family, key)
rows = fields[key_hash('m_params')]['array']['items']
result['materials']['Retail_'+slot.name] = dict(shader=shader,
params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows])
specular = components[slot.name]['textures'].get('specular')
if specular:
# The native skin shader consumes R, not luminance or roughness.
source = private / 'default_skater/textures/decoded' / (specular+'.png')
destination = private / 'native-character' / (slot.name+'_specular.png')
destination.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
image.convert('RGB').getchannel('R').save(destination)
result['materials']['Retail_'+slot.name]['specular'] = 'private/native-character/'+destination.name
fields, _ = collections.resolve('Hash_6A2B5946514B8490', 'freeskate')
result['default_sh'] = [struct.unpack('>3f', bytes.fromhex(row))
for row in fields[key_hash('Hash_F45DFCAB6EC195CD')]['array']['items']]
(private/'character-lighting.json').write_text(json.dumps(result))
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--models', type=Path, required=True)
p.add_argument('--private', type=Path, required=True)
p.add_argument('--collections', type=Path, required=True)
a = p.parse_args()
convert(a.models, a.private, json.loads(a.collections.read_text()))
+221
View File
@@ -0,0 +1,221 @@
"""Export authored district DMO locators and their referenced model geometry.
This preserves initial placement, not DMO simulation. Native 0x825876D0 reads
the locator matrix and template ID at +112; RX2 EB001D records are 128 bytes.
No name matching, random placement, ground snapping or collision synthesis.
"""
import argparse
import copy
import hashlib
import json
import struct
import sys
import tempfile
from pathlib import Path
import numpy as np
from .map_writer import write
from tools.owned_game.big import BigArchive
def sections(raw):
if raw[:7] != b'\x89RW4xb2':
raise ValueError('Expected Xbox RX2')
count = struct.unpack_from('>I', raw, 32)[0]
table = struct.unpack_from('>I', raw, 48)[0]
if table + count*24 > len(raw):
raise ValueError('Truncated RX2 section table')
return [struct.unpack_from('>6I', raw, table+i*24) for i in range(count)]
def matrix(raw, at):
value = np.array(struct.unpack_from('>16f', raw, at)).reshape(4, 4)
if not np.isfinite(value).all() or not np.allclose(value[:, 3], [0, 0, 0, 1], atol=1e-5):
raise ValueError('Invalid DMO affine matrix')
if abs(np.linalg.det(value[:3, :3])) < 1e-8:
raise ValueError('Singular DMO matrix')
return value
def records(raw, kind, stride):
for offset, _, size, _, _, type_id in sections(raw):
if type_id != kind:
continue
if offset+size > len(raw) or size < 32:
raise ValueError('Invalid DMO section extent')
count = struct.unpack_from('>I', raw, offset+4)[0]
start, strings = struct.unpack_from('>2I', raw, offset+12)
if start != 32 or strings != start+count*stride or strings > size:
raise ValueError('Unexpected DMO record layout')
for index in range(count):
yield offset, offset+start+index*stride, size
def locators(raw):
result = []
for base, at, size in records(raw, 0xEB001D, 128):
name_offset = struct.unpack_from('>I', raw, at+124)[0]
if not 32 <= name_offset < size:
raise ValueError('Invalid DMO locator name offset')
end = raw.index(b'\0', base+name_offset, base+size)
result.append(dict(instance_id=f'{struct.unpack_from(">Q", raw, at+96)[0]:016X}',
locator_id=f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}',
template_id=f'{struct.unpack_from(">Q", raw, at+112)[0]:016X}',
matrix=matrix(raw, at).tolist(), name=raw[base+name_offset:end].decode('utf-8'),
source_offset=at, bounds=np.array(struct.unpack_from('>8f', raw, at+64)).reshape(2, 4)[:, :3].tolist()))
return result
def template_meshes(raw):
"""Resolve EB000D tInstance -> EB0001 model -> EB0023 mesh -> declaration."""
table = sections(raw)
result = {}
for _, at, _ in records(raw, 0xEB000D, 160):
key = f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}'
index = struct.unpack_from('>I', raw, at+128)[0]
if index >= len(table) or table[index][5] != 0xEB0001:
raise ValueError('Unresolved DMO model reference')
base, _, size, _, _, _ = table[index]
start = struct.unpack_from('>I', raw, base+36)[0]
count = struct.unpack_from('>H', raw, base+48)[0]
if base+size > len(raw) or start+count*8 > size:
raise ValueError('Invalid DMO model mesh table')
mesh_info = []
for j in range(count):
mesh_index = struct.unpack_from('>I', raw, base+start+j*8)[0]
if mesh_index >= len(table) or table[mesh_index][5] != 0xEB0023:
raise ValueError('Unresolved DMO mesh reference')
descriptor = struct.unpack_from('>I', raw, table[mesh_index][0]+40)[0]
if descriptor >= len(table) or table[descriptor][5] != 0x200E9:
raise ValueError('Unresolved DMO vertex declaration')
mesh_info.append(table[descriptor][0])
if key in result:
raise ValueError('Duplicate DMO template ID')
result[key] = dict(mesh_info=mesh_info, matrix=matrix(raw, at),
model_matrix=matrix(raw, base+struct.unpack_from('>I', raw, base+32)[0]))
return result
def transform_mesh(arrays, index, transform):
values = {key: np.array(value, copy=True) for key, value in arrays.items() if key.endswith('_'+str(index))}
key = f'vertices_{index}'
values[key] = (values[key] @ transform[:3, :3] + transform[3, :3]).astype('f4')
normal_matrix = np.linalg.inv(transform[:3, :3]).T
for prefix in ('normals', 'retail_normals'):
key = f'{prefix}_{index}'
if key in values:
n = values[key] @ normal_matrix
values[key] = (n/np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-20)).astype('f4')
if np.linalg.det(transform[:3, :3]) < 0:
values[f'faces_{index}'] = values[f'faces_{index}'][:, [0, 2, 1]]
return values
def catalog(cache_roots):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
import rx2_parser
from .backdrop import texture_groups
templates, textures = {}, {}
for root in cache_roots:
m = json.loads((root/'manifest.json').read_text())
for key, value in m['textures'].items():
entry = dict(value)
for path_key in ('rgba', 'png'):
if path_key in entry:
entry[path_key] = str((root/entry[path_key]).resolve())
if key in textures and Path(textures[key]['rgba']).read_bytes() != Path(entry['rgba']).read_bytes():
raise ValueError('Conflicting DMO texture '+key)
textures[key] = entry
for model in m['models']:
raw = (root/model['rx2']).read_bytes()
table = rx2_parser.RX2File(raw)
table.parse()
roles = set(role for mesh in model['meshes'] for role in mesh['retail_texture_ids'])
bindings = texture_groups(raw, table, roles)
for key, definition in template_meshes(raw).items():
meshes = [copy.deepcopy(mesh) for mesh in model['meshes'] if mesh['source_offsets']['mesh_info'] in definition['mesh_info']]
for mesh in meshes:
# DMO resources have a high-bit namespace that display names
# omit. Bind the binary channel GUID; never strip that bit.
group = bindings[mesh['retail_material_group_index']]
mesh['retail_texture_ids'] = {role:f'0x{group[role]:016x}' for role in mesh['retail_texture_ids']}
mesh['texture_id'] = mesh['retail_texture_ids'].get('diffuse', mesh['retail_texture_ids'].get('transparent'))
if len(meshes) != len(definition['mesh_info']):
raise ValueError('DMO model geometry omitted '+key)
if key in templates:
raise ValueError('Conflicting DMO template '+key)
templates[key] = dict(definition, meshes=meshes, npz=root/model['npz'], asset_id=model['asset_id'])
return templates, textures
def export(manifest_path, cache_roots, output):
district = json.loads(manifest_path.read_text())
templates, textures = catalog(cache_roots)
placements = {}
for source in district['simulation_assets']:
raw = (manifest_path.parent/source['rx2']).read_bytes()
for item in locators(raw):
item['source_asset'] = source['asset_id']
item['source_sha256'] = hashlib.sha256(raw).hexdigest()
key = item['instance_id']
if key in placements and placements[key] != item:
raise ValueError('Conflicting DMO locator '+key)
placements[key] = item
report = dict(map=district['map_name'], instances=[], unresolved=[], simulation='initial placement only')
with tempfile.TemporaryDirectory(prefix='skate-dmo-') as work:
root = Path(work); models = []; used = set()
for number, item in enumerate(placements.values()):
template = templates.get(item['template_id'])
if template is None:
report['unresolved'].append(item)
continue
transform = template['model_matrix'] @ template['matrix'] @ np.array(item['matrix'])
arrays = {}; meshes = copy.deepcopy(template['meshes'])
with np.load(template['npz'], allow_pickle=False) as original:
for mesh in meshes:
arrays.update(transform_mesh(original, mesh['index'], transform))
used.update(mesh['retail_texture_ids'].values())
path = f'{number}.npz'; np.savez(root/path, **arrays)
models.append(dict(asset_id=item['instance_id'], meshes=meshes, npz=path))
report['instances'].append(dict(item, model_asset=template['asset_id'], meshes=len(meshes)))
if models:
manifest = dict(map_name=district['map_name'], district_name=district['district_name'],
models=models, textures={key:textures[key] for key in used},
normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[])
(root/'manifest.json').write_text(json.dumps(manifest))
# Publish only a complete package; preserve the previous one on failure.
temporary = output.with_suffix('.skate.new')
try:
write(root/'manifest.json', temporary, None, render_only=True)
temporary.replace(output)
finally:
temporary.unlink(missing_ok=True)
else:
output.unlink(missing_ok=True)
output.parent.mkdir(parents=True, exist_ok=True)
output.with_suffix('.json').write_text(json.dumps(report, indent=2))
return len(report['instances']), len(report['unresolved'])
def prepare_catalog(game_root, work):
vendor = Path(__file__).resolve().parents[1]/'vendor'
sys.path.insert(0, str(vendor/'university/tools/vanilla_map_extraction/tools'))
from prepare_hawaiian_dream import prepare
archive = BigArchive(game_root/'data/content/worlddmo.big')
archive.extract_entries(archive.entries, work/'raw')
roots = []
for stream in (work/'raw/data/content/world/dmo').iterdir():
root = work/'cache'/stream.name
prepare(stream_directory=stream, output_root=root, utt_root=vendor/'utt',
district_name=stream.name, map_name=stream.name, raw_texture_cache=True)
roots.append(root)
return roots
if __name__ == '__main__':
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--manifest', type=Path, required=True)
p.add_argument('--cache', type=Path, nargs='+', required=True)
p.add_argument('--output', type=Path, required=True)
a = p.parse_args()
print('DMO placed/unresolved:', export(a.manifest, a.cache, a.output))
+99
View File
@@ -0,0 +1,99 @@
"""Resolve authored world/environment references without inventing frame rows."""
import math
import struct
from .vlt import hash64
# Names recovered from schema/debug strings; unnamed fields keep their hashes.
NAMES = ('world', 'render_locations', 'material_sky', 'material_fog',
'sun_position', 'm_params', 'fog_far', 'fog_near', 'fog_colour',
'fog_power', 'fog_max')
def key_hash(name):
return int(name[5:], 16) if name.startswith('Hash_') else hash64(name)
class Collections:
def __init__(self, data):
self.rows = {(key_hash(r['class']), key_hash(r['key'])): r
for r in data['collections']}
def resolve(self, cls, key):
cls = key_hash(cls) if isinstance(cls, str) else cls
key = key_hash(key) if isinstance(key, str) else key
fields, chain, seen = {}, [], set()
while key:
if key in seen:
raise ValueError('Cyclic environment inheritance')
seen.add(key)
row = self.rows[cls, key]
chain.append(row['key'])
for name, value in row['fields'].items():
fields.setdefault(key_hash(name), value)
key = key_hash(row['parent'])
return fields, chain
def field(fields, name):
return fields[key_hash(name)]['data']
def floats(raw, count):
values = struct.unpack('>' + 'f' * count, bytes.fromhex(raw))
if not all(math.isfinite(v) for v in values):
raise ValueError('Non-finite environment value')
return list(values)
def world_environment(collections, world_key):
world, world_chain = collections.resolve('world', world_key)
location_key = struct.unpack('>Q', bytes.fromhex(
field(world, 'Hash_7E23D10785C43717'))[:8])[0]
location, location_chain = collections.resolve('render_locations', location_key)
sun = floats(field(location, 'sun_position'), 3)
length = math.sqrt(sum(v*v for v in sun))
if length < 1e-6:
raise ValueError('Invalid authored sun direction')
fog_cls, fog_key, _ = struct.unpack('>3Q', bytes.fromhex(field(location, 'material_fog')))
fog, fog_chain = collections.resolve(fog_cls, fog_key)
return {
'world_chain': world_chain, 'location_chain': location_chain,
'sun_direction': [v/length for v in sun],
'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0],
'sky_model': field(world, 'Hash_C7A0A84F018E87BA'),
'sky_textures': field(world, 'Hash_2A0BF629355AFB15'),
# Keep the authored values alongside the derived native shader rows.
'fog': {'chain': fog_chain, **{n: floats(field(fog, n), 4 if n == 'fog_colour' else 1)
for n in ('fog_near', 'fog_far', 'fog_colour', 'fog_power', 'fog_max')}},
}
def sky_parameters(collections, shader):
effect, key = shader.split('.', 1)
if effect != 'sky':
raise ValueError('Not a sky material: ' + shader)
fields, chain = collections.resolve('material_sky', key)
params = fields[hash64('m_params')].get('array')
if not params or not params['items']:
raise ValueError('Sky parameters require complete VLT array payloads')
row = floats(params['items'][0], 4)
if row[0] <= 0 or row[1] < 0:
raise ValueError('Invalid authored sky parameters')
return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]}
def fog_parameters(fog):
"""TU3 0x828012D0: schema offsets 20/28/16/24 -> ramp and colour.
0x82F825F0 initializes the negative-one vector used for colour alpha.
Colour RGB is already linear here; do not square it again.
"""
near, far, power, maximum = (fog[n][0] for n in
('fog_near', 'fog_far', 'fog_power', 'fog_max'))
colour = fog['fog_colour']
if (len(colour) != 4 or not all(math.isfinite(v) for v in
[near, far, power, maximum, *colour]) or far <= near or near < 0
or power <= 0 or not 0 <= maximum <= 1):
raise ValueError('Invalid authored fog range/colour')
return {'ramp': [1 / (far - near), -near / (far - near), power, 0],
'colour': [v * maximum for v in colour[:3]] + [-maximum]}
+20 -1
View File
@@ -120,6 +120,12 @@ def convert_map(archive,work,maps,stage,game_exe,log,report):
build_archive(manifest_path,collision)
final=maps/(label+'.skate')
write_map(manifest_path,final,collision,report)
from .dynamic_props import export as write_props
caches=list((work/'dmo/cache').glob('DMO_*'))
if not caches:
raise RuntimeError('Missing prepared DMO catalog')
placed, unresolved=write_props(manifest_path,caches,stage/'assets/private/native-props'/(label+'.skate'))
report(f'{label}: placed {placed} authored DMO instances, {unresolved} unresolved templates')
report('Checking converted map: '+label)
run([game_exe,'--assets',stage/'assets','--map',final,'--check-assets'],log,report)
entry={'name':label,'path':'maps/'+final.name,'sha256':digest(final)}
@@ -188,13 +194,26 @@ def install(iso,base,game_exe,report,game_root=None):
report('Building the skater model and rig')
from .character_glb import convert as write_character
write_character(character/'selected/models',private,manifest)
from .character_lighting import convert as write_character_lighting
write_character_lighting(character/'selected/models',private,converted)
game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000',
'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph',
'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'}
(private/'game.json').write_text(json.dumps(game_manifest),encoding='utf-8')
report('Preparing retail sky domes')
from .sky import convert as write_skies
write_skies(game_root,stage/'assets')
write_skies(game_root,stage/'assets',converted)
from .render_parameters import convert as write_render_parameters
write_render_parameters(stage/'assets',converted)
report('Extracting original travel destinations and location names')
from .teleports import convert as write_teleports
write_teleports(game_root,stage/'assets',converted)
report('Preparing global foliage backdrops')
from .backdrop import convert as write_backdrops
write_backdrops(game_root,stage/'assets',converted)
report('Preparing authored movable-object models')
from .dynamic_props import prepare_catalog
prepare_catalog(game_root,work/'dmo')
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'))
+51
View File
@@ -0,0 +1,51 @@
"""Export retail RWOBJECTTYPE_IRRADIANCEDATA (0xEB0024) without resampling."""
import json
import math
import struct
from pathlib import Path
def decode(raw):
if raw[:7] != b'\x89RW4xb2':
raise ValueError('Not an Xbox RX2 resource')
count, table = struct.unpack_from('>I', raw, 32)[0], struct.unpack_from('>I', raw, 48)[0]
groups = []
for i in range(count):
offset, _, size, _, _, kind = struct.unpack_from('>6I', raw, table + i*24)
if kind != 0xEB0024:
continue
n, start = struct.unpack_from('>2I', raw, offset)
if start < 8 or start+n*160 > size or offset+size > len(raw):
raise ValueError('Invalid irradiance record extent')
records = []
for j in range(n):
values = struct.unpack_from('>39fI', raw, offset+start+j*160)
if not all(math.isfinite(x) for x in values[:39]):
raise ValueError('Non-finite irradiance sample')
records.append(struct.pack('<39fI', *values))
if records:
groups.append(records)
return groups
def write(manifest_path, output):
manifest = json.loads(manifest_path.read_text())
groups = []
for asset in manifest['other_presentation_assets']:
groups.extend(decode((manifest_path.parent / asset['rx2']).read_bytes()))
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('wb') as f:
f.write(b'SHP1' + struct.pack('<I', len(groups)))
for records in groups:
f.write(struct.pack('<I', len(records)))
f.write(b''.join(records))
return sum(map(len, groups))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
print('Exported', write(args.manifest, args.output), 'irradiance samples')
+9 -4
View File
@@ -59,7 +59,7 @@ def spawn_point(manifest,root):
if best is None:raise ValueError('No supported spawn surface in '+manifest['map_name'])
return best[1]
def write(manifest_path,output,collision,report=lambda _:None):
def write(manifest_path,output,collision,report=lambda _:None, *, render_only=False):
root=manifest_path.parent;m=json.loads(manifest_path.read_text());textures=m['textures']
ids={name:i+1 for i,name in enumerate(sorted(textures))}
excluded=set(m['normal_texture_policy']['excluded_texture_ids'])
@@ -115,7 +115,7 @@ def write(manifest_path,output,collision,report=lambda _:None):
record['frame'][:,:3]=np.rint(np.clip(binormal,-1,1)*127).astype('i1');record['frame'][:,3]=np.rint(sign*127).astype('i1')
if not np.isfinite(pos).all():raise ValueError('Non-finite map geometry')
vertices.write(record.tobytes());indices.write((faces+nv).astype('<u4').tobytes());nv+=len(pos);ni+=faces.size;nm+=1
report('Selecting starting position: '+m['map_name']);spawn=spawn_point(m,root)
report('Selecting starting position: '+m['map_name']);spawn=(0.,0.,0.) if render_only else spawn_point(m,root)
# Match the supplied exporter's environment defaults. Native sky shaders
# remain a separate runtime feature; no geometry is synthesized here.
environment=[.10,.36,.75,.64,.82,1.,.18,.24,.30,0.,11.,0.,18.,0.,
@@ -145,10 +145,15 @@ def write(manifest_path,output,collision,report=lambda _:None):
raw=bytes.fromhex(segment)
if len(raw)!=120:raise ValueError('Invalid native spline segment')
f.write(np.frombuffer(raw,dtype='>u4').astype('<u4').tobytes())
u(f,2)
for tag,data in [(b'RWCM',collision.read_bytes()),(b'WMET',json.dumps(m,separators=(',',':')).encode())]:
extensions=[(b'WMET',json.dumps(m,separators=(',',':')).encode())]
if not render_only:extensions.insert(0,(b'RWCM',collision.read_bytes()))
u(f,len(extensions))
for tag,data in extensions:
f.write(tag);u(f,1,len(data));stored(f,data)
report('Map written: '+m['map_name'])
if not render_only:
from tools.asset_pipeline.irradiance import write as write_irradiance
write_irradiance(manifest_path, output.with_suffix('.irradiance'))
if __name__=='__main__':
import argparse
+41
View File
@@ -0,0 +1,41 @@
"""Extract TU3's built-in PCA ocean animation from an owned mapped image.
Addresses are corroborated by cPCAWaterAnimationData::Init (827905B0) and
its update routine (82790858). No game content is embedded in this tool.
"""
import hashlib
import json
import math
import struct
from pathlib import Path
def extract(image, output, base=0x82000000):
data = Path(image).read_bytes()
identity = hashlib.sha256(data).hexdigest()
if identity != 'f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4':
raise ValueError('Unsupported mapped executable build for PCA table extraction')
frames = []
for frame in range(30):
mean = struct.unpack_from('>3f', data, 0x830118D8-base+frame*12)
weights = struct.unpack_from('>24f', data, 0x83011A40-base+frame*96)
if not all(math.isfinite(v) for v in (*mean, *weights)):
raise ValueError('Non-finite ocean PCA table')
# Shader model order: mean X/Z/Y and weight pairs R/B/G.
rows = [[mean[0]/255., mean[2]/255., mean[1]/255., 0.]]
for start in (0, 4, 16, 20, 8, 12):
rows.append([v/255. for v in weights[start:start+4]])
frames.append(rows)
output = Path(output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(dict(source_sha256=identity, hz=30., frames=frames)), encoding='utf-8')
return len(frames)
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--image', type=Path, required=True)
p.add_argument('--output', type=Path, required=True)
a = p.parse_args()
print('Extracted', extract(a.image, a.output), 'ocean PCA frames')
+105
View File
@@ -0,0 +1,105 @@
"""Refresh SKATE14 texture payloads from a newly decoded RX2 manifest cache.
Material, geometry, collision and metadata bytes remain unchanged. Publication
is atomic and never overwrites the input map. No renderer is launched.
"""
import hashlib
import json
import os
import struct
import tempfile
import zlib
from pathlib import Path
import numpy as np
def refresh(manifest, source, output):
manifest, source, output = map(Path, (manifest, source, output))
if source.resolve() == output.resolve():
raise ValueError('Refresh requires a separate output map')
data = json.loads(manifest.read_text())
output.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with source.open('rb') as src:
def read(n):
value = src.read(n)
if len(value) != n:
raise ValueError('Truncated SKATE14 package')
return value
def uint():
return struct.unpack('<I', read(4))[0]
def text():
return read(uint()).decode('utf-8')
if read(8) != b'SKATE14\0' or uint() != 0x12345678:
raise ValueError('Texture refresh requires SKATE14')
text()
read(49 * 4)
counts = [uint() for _ in range(9)]
for _ in range(counts[0]):
text()
read(4 + 7*4 + 8 + 4 + 16 + 4 + 16)
if uint():
read(16)
text()
read(8)
for _ in range(uint()):
text()
read(16)
for _ in range(uint()):
text()
for _ in range(uint()):
text()
text()
prefix_end = src.tell()
src.seek(0)
prefix = read(prefix_end)
changed = []
with tempfile.NamedTemporaryFile(dir=output.parent, suffix='.skate.tmp', delete=False) as dst:
temporary = Path(dst.name)
dst.write(prefix)
for _ in range(counts[1]):
start = src.tell()
name = text()
width, height, _space = uint(), uint(), uint()
data_start = src.tell()
method, size = uint(), uint()
packed = read(size)
if method not in (0, 1):
raise ValueError('Unsupported texture compression')
old = zlib.decompress(packed) if method else packed
end = src.tell()
entry = data['textures'][name]
if (width, height) != (entry['width'], entry['height']):
raise ValueError('Texture dimensions changed: ' + name)
cache = (manifest.parent / entry['rgba']).read_bytes()
if len(cache) != width*height*4 or len(old) != len(cache):
raise ValueError('Texture byte count mismatch: ' + name)
array = np.frombuffer(cache, np.uint8).reshape(height, width, 4)
new = (array if entry.get('cube_faces') == 6 else array[::-1]).tobytes()
src.seek(start)
dst.write(read(data_start-start))
src.seek(end)
if new != old:
replacement = zlib.compress(new, 1)
dst.write(struct.pack('<II', 1, len(replacement)))
dst.write(replacement)
changed.append(dict(name=name, width=width, height=height, format=entry.get('format'),
changed_pixels=int(np.any(np.frombuffer(old, np.uint8).reshape(-1, 4) !=
np.frombuffer(new, np.uint8).reshape(-1, 4), axis=1).sum())))
else:
dst.write(struct.pack('<II', method, size))
dst.write(packed)
old_tail_offset, new_tail_offset = src.tell(), dst.tell()
tail_hash = hashlib.sha256()
while chunk := src.read(1024*1024):
tail_hash.update(chunk)
dst.write(chunk)
os.replace(temporary, output)
temporary = None
return dict(changed=changed, count=len(changed), textures=counts[1],
prefix_sha256=hashlib.sha256(prefix).hexdigest(), tail_sha256=tail_hash.hexdigest(),
old_tail_offset=old_tail_offset, new_tail_offset=new_tail_offset)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
+49
View File
@@ -0,0 +1,49 @@
"""Export complete authored parameter rows for additional presentation shaders."""
import json
import struct
from .environment import Collections, key_hash
def exposure_parameters(collections, location):
fields, chain = collections.resolve('render_locations', location)
key = int(fields[key_hash('Hash_C345507C4B9B6F62')]['data'][:16], 16)
rendering, rendering_chain = collections.resolve('rendering', key)
values = {n: struct.unpack('>f', bytes.fromhex(rendering[key_hash('auto_exposure_'+n)]['data']))[0]
for n in ('target_luminance', 'min', 'max', 'damping')}
return dict(values, location_chain=chain, rendering_chain=rendering_chain)
def convert(assets, converted):
collections = Collections(converted)
result = {}
aliases = ('default', 'reflection', 'backlituvscroll', 'transparent',
'flowing', 'flowingalpha', 'alpha', 'skatepark', 'videoscreen')
for (cls, key), row in collections.rows.items():
for family in ('water', 'ocean', 'incandescent'):
if cls != key_hash('material_' + family):
continue
fields, _ = collections.resolve(cls, key)
raw = fields.get(key_hash('m_params'), {}).get('array', {}).get('items', [])
if not raw:
continue
name = next((n for n in aliases if key_hash(n) == key), row['key'])
result[family + '.' + name] = [struct.unpack('>4f', bytes.fromhex(r)) for r in raw]
path = assets / 'private/render-parameters.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(result), encoding='utf-8')
fields, _ = collections.resolve('Hash_1FFDC8E3ACA07C1F', 'auto_exposure')
exposure = {n: struct.unpack('>f', bytes.fromhex(fields[key_hash('auto_exposure_'+n)]['data']))[0]
for n in ('target_luminance', 'min', 'max', 'damping')}
(assets / 'private/exposure.json').write_text(json.dumps(exposure), encoding='utf-8')
profiles = {}
for (cls, key), row in collections.rows.items():
if cls != key_hash('world'):
continue
own_stream = next((v['data'] for k, v in row['fields'].items() if key_hash(k) == key_hash('WorldStream')), None)
if not own_stream or not own_stream.startswith('DIST_'):
continue
world, _ = collections.resolve(cls, key)
location = int(world[key_hash('Hash_7E23D10785C43717')]['data'][:16], 16)
profiles[own_stream.removeprefix('DIST_').casefold()] = exposure_parameters(collections, location)
(assets / 'private/exposure-profiles.json').write_text(json.dumps(profiles, indent=2), encoding='utf-8')
return len(result)
+2
View File
@@ -31,6 +31,8 @@ def _retail_shader_family(shader_name: str) -> int:
return 12
if shader.startswith("water.flowing"):
return 30
if shader in ("water.default", "water.alpha", "water.skatepark"):
return 33
if shader.startswith("ocean.default"):
return 31
if shader.startswith("ocean.reflection"):
+108 -39
View File
@@ -1,43 +1,112 @@
"""Extract retail sky geometry and textures through RX2 GUID bindings."""
import json,struct,sys
"""Extract authored retail skies through world references and RX2 GUID bindings."""
import json
import struct
import sys
from pathlib import Path
from tools.owned_game.big import BigArchive
from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment, fog_parameters
def convert(game_root, assets):
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'vendor/utt'))
import mdl_parser,rx2_parser
archive=BigArchive(game_root/'data/big/miscload.big')
entries={e.path:e for e in archive.entries}
output=assets/'private/native-skies';output.mkdir(parents=True,exist_ok=True)
for name,suffix in [('University',''),('DownTown','_downtown'),('Industrial','_industrial')]:
prefix='data/content/world/models/DIST_skybox'+suffix
raw=archive.read(entries[prefix+'.rx2'])
model=mdl_parser.parse_rx2(raw)
table=rx2_parser.RX2File(raw);table.parse()
diffuse=None
for section in table.entries:
if section.type_id!=0x00eb0005:continue
o=section.f0;h=struct.unpack_from('>8I',raw,o)
if (h[4]-h[3])//h[1]!=32:raise ValueError('Unexpected sky material layout')
for i in range(h[1]):
v=struct.unpack_from('>8I',raw,o+h[3]+i*32)
at=o+v[0];kind=raw[at:raw.index(b'\0',at)]
if kind==b'diffuse':diffuse=(v[4]<<32)|v[5]
textures=rx2_parser.parse_rx2(archive.read(entries[prefix+'_Textures.rx2']))
handle=None
for section in textures.entries:
if section.type_id!=0x00eb000b:continue
o=section.f0;n,start=struct.unpack_from('>2I',textures.data,o)
for i in range(n):
_,_,hi,lo,kind,index=struct.unpack_from('>6I',textures.data,o+start+i*24)
if (hi<<32)|lo==diffuse:handle=index
texture=next((t for t in textures.textures if t.index==handle),None)
if texture is None:raise ValueError('Sky diffuse GUID did not resolve: '+name)
if len(model.meshes)!=1 or model.meshes[0].uvs is None:raise ValueError('Unexpected sky mesh layout')
mesh=model.meshes[0]
(output/(name+'.rgba')).write_bytes(texture.rgba)
def _material(raw, table):
channels = {}
for section in table.entries:
if section.type_id != 0x00eb0005:
continue
o = section.f0
h = struct.unpack_from('>8I', raw, o)
if not h[1] or (h[4]-h[3])//h[1] != 32:
raise ValueError('Unexpected sky material layout')
for i in range(h[1]):
v = struct.unpack_from('>8I', raw, o+h[3]+i*32)
at = o+v[0]
kind = raw[at:raw.index(b'\0', at)].decode('ascii')
if kind in ('diffuse', 'specular'):
channels[kind] = (v[4]<<32)|v[5]
return channels
def _texture(textures, guid):
handle = None
for section in textures.entries:
if section.type_id != 0x00eb000b:
continue
o = section.f0
n, start = struct.unpack_from('>2I', textures.data, o)
for i in range(n):
_, _, hi, lo, _, index = struct.unpack_from('>6I', textures.data, o+start+i*24)
if (hi<<32)|lo == guid:
handle = index
texture = next((t for t in textures.textures if t.index == handle), None)
if texture is None:
raise ValueError(f'Sky texture GUID did not resolve: {guid:016X}')
return texture
def convert(game_root, assets, converted):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
import mdl_parser
import rx2_parser
collections = Collections(converted)
archive = BigArchive(game_root/'data/big/miscload.big')
entries = {e.path: e for e in archive.entries}
output = assets/'private/native-skies'
output.mkdir(parents=True, exist_ok=True)
available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')}
count = 0
for (cls, _), row in collections.rows.items():
if cls != key_hash('world'):
continue
own_stream = next((v['data'] for k, v in row['fields'].items()
if key_hash(k) == key_hash('WorldStream')), None)
if own_stream not in available:
continue
name = own_stream.removeprefix('DIST_')
env = world_environment(collections, row['key'])
raw = archive.read(entries['data/content/'+env['sky_model']+'.rx2'])
model = mdl_parser.parse_rx2(raw)
table = rx2_parser.RX2File(raw)
table.parse()
channels = _material(raw, table)
textures = rx2_parser.parse_rx2(archive.read(entries['data/content/'+env['sky_textures']+'.rx2']))
diffuse = _texture(textures, channels['diffuse'])
sun = _texture(textures, channels['specular'])
shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName')
env.update(sky_parameters(collections, shader))
env['fog_frame'] = fog_parameters(env['fog'])
if len(model.meshes) != 1 or model.meshes[0].uvs is None:
raise ValueError('Unexpected sky mesh layout')
mesh = model.meshes[0]
if len(sun.rgba) != sun.width*sun.height*4 or sun.height != 16:
raise ValueError('Unexpected sky sun-gradient dimensions')
(output/(name+'.rgba')).write_bytes(diffuse.rgba)
(output/(name+'.sun.rgba')).write_bytes(sun.rgba)
(output/(name+'.json')).write_text(json.dumps({
'width':texture.width,'height':texture.height,
'positions':mesh.vertices.tolist(),'uvs':mesh.uvs.tolist(),
'indices':mesh.faces.reshape(-1).tolist(),
},separators=(',',':')),encoding='utf-8')
'width': diffuse.width, 'height': diffuse.height,
'positions': mesh.vertices.tolist(), 'uvs': mesh.uvs.tolist(),
'indices': mesh.faces.reshape(-1).tolist(),
'environment': env, 'sun_width': sun.width, 'sun_height': sun.height,
'source_sha256': row['sha256'],
}, separators=(',', ':')), encoding='utf-8')
count += 1
if not count:
raise ValueError('No authored world skies resolved')
return count
if __name__ == '__main__':
import argparse
import tempfile
from .vlt import convert as convert_vlt
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--game-root', type=Path, required=True)
parser.add_argument('--assets', type=Path, required=True)
args = parser.parse_args()
# A targeted update reads only the small database pair and sky resources.
with tempfile.TemporaryDirectory(prefix='skate-sky-') as work:
database = BigArchive(args.game_root/'data/big/db.big')
needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'}
database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work))
stem = Path(work)/'data/db'
names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines()
converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', [*names, *NAMES])
print('Prepared', convert(args.game_root, args.assets, converted), 'authored skies')
+120
View File
@@ -0,0 +1,120 @@
"""Join retail FE destinations to localized names and RX2 location matrices.
Owned content stays in private sidecars. No guessed coordinates or renamed spots.
LocationManager 828A3D50 loads data/content/global_locators; EB0009 records
contain a 64-byte matrix and a section-relative name pointer at +104.
"""
import hashlib
import json
import struct
import tempfile
from pathlib import Path
from tools.owned_game.big import BigArchive
from .dynamic_props import sections, matrix
from .environment import Collections, key_hash
from .vlt import vault
def cstring(raw, offset, limit=None):
limit = len(raw) if limit is None else limit
if not 0 <= offset < limit <= len(raw):
raise ValueError('Invalid location string offset')
return raw[offset:raw.index(b'\0', offset, limit)]
def language_table(raw):
if len(raw) < 36 or struct.unpack_from('<I', raw)[0] != 0x39000:
raise ValueError('Unsupported retail language table')
count, start, strings = struct.unpack_from('<3I', raw, 8)
start += 8
strings += 8
if start + count * 8 != strings or strings > len(raw):
raise ValueError('Invalid retail language index')
result = {}
for index in range(count):
key, offset = struct.unpack_from('<2I', raw, start + index * 8)
if key in result:
raise ValueError('Duplicate language key')
result[key] = cstring(raw, strings + offset)
return result
def location_records(raw):
result = []
for base, _, size, _, _, kind in sections(raw):
if kind != 0xEB0009:
continue
count, _, _, start, end = struct.unpack_from('>5I', raw, base)
if base + size > len(raw) or start != 32 or end != start + count * 128 or end > size:
raise ValueError('Invalid location record table')
for index in range(count):
at = base + start + index * 128
pointer = struct.unpack_from('>I', raw, at + 104)[0]
if not end <= pointer < size:
raise ValueError('Location name overlaps records')
result.append({'locator': cstring(raw, base + pointer, base + size).decode('utf-8'),
'matrix': matrix(raw, at).tolist(), 'source_offset': at})
return result
def convert(game_root, assets, converted):
game_root, assets = Path(game_root), Path(assets)
archive = BigArchive(game_root / 'data/big/miscboot.big')
tables = {}
for language in ('english', 'labels'):
entry = next(e for e in archive.entries if f'/{language}/' in e.path and '_Global_' in e.path)
tables[language] = language_table(archive.read(entry))
labels = {value.strip().decode('ascii'): key for key, value in tables['labels'].items()}
database = BigArchive(game_root / 'data/big/db.big')
with tempfile.TemporaryDirectory(prefix='skate-teleports-') as tmp:
root = Path(tmp)
entries = [e for e in database.entries if e.path in
('data/db/skatercollections.bin', 'data/db/skatercollections.vlt')]
database.extract_entries(entries, root)
_, binary, _ = vault(root / 'data/db/skatercollections')
locators = {}
for path in sorted((game_root / 'data/content/global_locators').rglob('*.rx2')):
raw = path.read_bytes()
stream = path.parent.name
for record in location_records(raw):
record.update(map=stream.removeprefix('DIST_'), source=str(path.relative_to(game_root)),
source_sha256=hashlib.sha256(raw).hexdigest())
locators.setdefault((stream.casefold(), record['locator']), []).append(record)
collections = Collections(converted)
destinations = []
# Own location artwork distinguishes selectable FE destinations from base
# templates, startup checkpoints and park-layout aliases.
for (cls, key), row in collections.rows.items():
if cls != key_hash('fe_locations') or not any(key_hash(k) == key_hash('Hash_786A2D24F475342B') for k in row['fields']):
continue
fields, _ = collections.resolve(cls, key)
label = fields[key_hash('Hash_174B301910A902C9')]['data']
if label not in labels:
continue # Template placeholders have no shipped localized name.
text = tables['english'][labels[label]]
# 0xAB is the retail font's skate wordmark glyph. The portable UI has
# no such font glyph; spell out the same brand instead of showing «.
name = text.replace(b'\xab', b'skate.').decode('ascii').strip()
locator = cstring(binary, int(fields[key_hash('location')]['data'], 16)).decode('utf-8')
world_key = int(fields[key_hash('World')]['data'][:16], 16)
world, _ = collections.resolve('world', world_key)
stream = world[key_hash('WorldStream')]['data']
if not (game_root / 'data/content' / ('world' + stream + '.big')).exists():
continue
matches = locators.get((stream.casefold(), locator), [])
unique = {json.dumps(m['matrix']) for m in matches}
if len(unique) > 1:
raise ValueError('Ambiguous authored teleport: ' + locator)
item = dict(id=row['key'], name=name, map=stream.removeprefix('DIST_'),
label_key=label, locator=locator, matrix=None)
if matches:
item.update(matches[0])
else:
item['unavailable_reason'] = 'Authored destination locator was not found in the extracted content'
destinations.append(item)
destinations.sort(key=lambda d: (d['map'].casefold(), d['name'].casefold()))
output = assets / 'private/teleports.json'
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps({'version': 1, 'destinations': destinations}, indent=2), encoding='utf-8')
return destinations
+44
View File
@@ -0,0 +1,44 @@
"""Global resource GUID selection without owned-game fixtures."""
import struct
import unittest
from types import SimpleNamespace
from .backdrop import texture_groups
from .sky import _texture
class BackdropTests(unittest.TestCase):
def test_missing_channels_do_not_shift_later_materials(self):
# Ocean has no diffuse here; foliage follows it. Name text and GUIDs
# deliberately differ, as they do in the global University resource.
parameters = [('Name', 0), ('normal', 19), ('Name', 0),
('diffuse', 0x123456789ABCDEF0), ('lightmap', 23)]
raw = bytearray(512)
struct.pack_into('>8I', raw, 0, 0, len(parameters), 0, 32,
32 + len(parameters)*32, 0, 0, 0)
at = 256
for i, (kind, guid) in enumerate(parameters):
word = kind.encode() + b'\0'
raw[at:at+len(word)] = word
struct.pack_into('>8I', raw, 32+i*32, at, 0, 0, 0,
guid >> 32, guid & 0xffffffff, 0, 0)
at += len(word)
table = SimpleNamespace(entries=[SimpleNamespace(type_id=0xeb0005, f0=0)])
self.assertEqual(texture_groups(bytes(raw), table, ('diffuse', 'normal', 'lightmap')),
[{'normal': 19}, {'diffuse': 0x123456789ABCDEF0, 'lightmap': 23}])
def test_texture_guid_uses_handle_not_list_position(self):
raw = bytearray(64)
struct.pack_into('>2I', raw, 0, 1, 8)
struct.pack_into('>6I', raw, 8, 0, 0, 0x12345678, 0x9abcdef0, 0, 27)
wanted = SimpleNamespace(index=27)
textures = SimpleNamespace(data=bytes(raw),
entries=[SimpleNamespace(type_id=0xeb000b, f0=0)],
textures=[SimpleNamespace(index=1), wanted])
self.assertIs(_texture(textures, 0x123456789ABCDEF0), wanted)
with self.assertRaises(ValueError):
_texture(textures, 123)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,52 @@
import struct
import unittest
import numpy as np
from .dynamic_props import locators, transform_mesh, template_meshes
def resource(kind, payload):
raw = bytearray(88+len(payload))
raw[:7] = b'\x89RW4xb2'
struct.pack_into('>I', raw, 32, 1)
struct.pack_into('>I', raw, 48, 64)
struct.pack_into('>6I', raw, 64, 88, 0, len(payload), 0, 0, kind)
raw[88:] = payload
return raw
class DynamicPropsTests(unittest.TestCase):
def test_locator_id_and_row_matrix(self):
payload = bytearray(166)
struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 160)
m = np.eye(4); m[3, :3] = [10, 20, -30]
struct.pack_into('>16f', payload, 32, *m.ravel())
struct.pack_into('>3Q2I', payload, 128, 123, 456, 789, 0, 160)
payload[160:] = b'bench\0'
rows = locators(resource(0xEB001D, payload))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['template_id'], '0000000000000315')
self.assertEqual(rows[0]['matrix'][3], [10, 20, -30, 1])
self.assertEqual(rows[0]['name'], 'bench')
struct.pack_into('>I', payload, 16, 159)
with self.assertRaisesRegex(ValueError, 'layout'):
locators(resource(0xEB001D, payload))
def test_scaled_rotation_and_normal_transform(self):
m = np.array([[0, 0, -2, 0], [0, 3, 0, 0], [4, 0, 0, 0], [10, 20, 30, 1]], dtype=float)
arrays = {'vertices_0': np.array([[1, 2, 3]]), 'normals_0': np.array([[0, 0, 1]]), 'faces_0': np.array([[0, 0, 0]])}
transformed = transform_mesh(arrays, 0, m)
np.testing.assert_allclose(transformed['vertices_0'], [[22, 26, 28]])
np.testing.assert_allclose(transformed['normals_0'], [[1, 0, 0]])
np.testing.assert_array_equal(arrays['vertices_0'], [[1, 2, 3]])
def test_missing_template_reference_is_rejected(self):
payload = bytearray(192)
struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 192)
struct.pack_into('>I', payload, 160, 100)
with self.assertRaisesRegex(ValueError, 'model reference'):
template_meshes(resource(0xEB000D, payload))
if __name__ == '__main__':
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Synthetic environment references, independent of owned game assets."""
import struct
import unittest
from .environment import Collections, field, key_hash, sky_parameters, world_environment, fog_parameters
from .render_parameters import exposure_parameters
def raw(*values):
return struct.pack('>'+'f'*len(values), *values).hex()
def row(cls, key, parent='', **fields):
return {'class': cls, 'key': key, 'parent': parent,
'fields': {k: {'type': 'fixture', 'data': v} for k, v in fields.items()}}
class EnvironmentTests(unittest.TestCase):
def test_exposure_follows_location_reference_and_inheritance(self):
rows = [row('render_locations', 'park', Hash_C345507C4B9B6F62=struct.pack('>2Q', key_hash('park_exposure'), 0).hex()),
row('rendering', 'base', auto_exposure_target_luminance=raw(.25), auto_exposure_min=raw(.75), auto_exposure_max=raw(2.5), auto_exposure_damping=raw(.5)),
row('rendering', 'park_exposure', 'base', auto_exposure_max=raw(1.5))]
result = exposure_parameters(Collections({'collections': rows}), 'park')
self.assertEqual(result['max'], 1.5)
self.assertEqual(result['min'], .75)
self.assertEqual(result['rendering_chain'], ['park_exposure', 'base'])
def test_world_reference_and_inheritance_select_default_fog(self):
rows = [
row('world', 'base', Hash_C7A0A84F018E87BA='world/models/sky',
Hash_2A0BF629355AFB15='world/models/sky_Textures',
Hash_7E23D10785C43717=struct.pack('>2Q', key_hash('park'), 0).hex()),
row('world', 'test', 'base'),
row('render_locations', 'default', sun_position=raw(1, 0, 0),
Hash_2E9AAECD1C29F81F=raw(150),
material_fog=struct.pack('>3Q', key_hash('material_fog'), key_hash('default'), 0).hex()),
row('render_locations', 'park', 'default', sun_position=raw(0, 3, 4)),
row('material_fog', 'default', fog_near=raw(20), fog_far=raw(1000),
fog_colour=raw(.1, .2, .3, 1), fog_power=raw(2), fog_max=raw(.5)),
row('material_fog', 'fog_default', 'default', fog_far=raw(200)),
]
env = world_environment(Collections({'collections': rows}), 'test')
self.assertEqual(env['sun_direction'], [0, .6, .8])
self.assertEqual(env['anchor_height'], 150)
self.assertEqual(env['location_chain'], ['park', 'default'])
self.assertEqual(env['fog']['fog_far'], [1000])
self.assertEqual(env['sky_model'], 'world/models/sky')
def test_native_fog_rows_and_distance_boundaries(self):
fog = dict(fog_near=[100.], fog_far=[2000.], fog_power=[2.],
fog_max=[.5], fog_colour=[.1, .2, .3, 1.])
frame = fog_parameters(fog)
self.assertEqual(frame['colour'], [.05, .1, .15, -.5])
def opacity(distance):
x = max(0., min(1., distance * frame['ramp'][0] + frame['ramp'][1]))
return .5 * x ** frame['ramp'][2]
self.assertEqual(opacity(0.), 0.)
self.assertAlmostEqual(opacity(100.), 0.)
self.assertAlmostEqual(opacity(1050.), .125)
self.assertAlmostEqual(opacity(2000.), .5)
self.assertAlmostEqual(opacity(3000.), .5)
fog['fog_far'] = [100.]
with self.assertRaises(ValueError): fog_parameters(fog)
def test_unnamed_hashes_and_cycle_rejection(self):
cls = f'Hash_{key_hash("world"):016X}'
c = Collections({'collections': [row(cls, 'a', 'b', value='child'),
row(cls, 'b', value='parent')]})
values, _ = c.resolve('world', 'a')
self.assertEqual(field(values, 'value'), 'child')
c.rows[key_hash('world'), key_hash('b')]['parent'] = 'a'
with self.assertRaises(ValueError):
c.resolve('world', 'a')
def test_sky_requires_complete_array_and_shader_collection(self):
r = row('material_sky', 'default')
r['fields']['m_params'] = {'type': 'Attrib::Types::Vector4', 'data': 'header'}
c = Collections({'collections': [r]})
with self.assertRaises(ValueError):
sky_parameters(c, 'sky.default')
r['fields']['m_params']['array'] = {'items': [raw(.5, .25, 0, 0)]}
self.assertEqual(sky_parameters(c, 'sky.default')['multiplier'], .25)
with self.assertRaises(KeyError):
sky_parameters(c, 'sky.missing')
if __name__ == '__main__':
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
"""RX2 irradiance layout checks without owned-game fixtures."""
import struct
import unittest
from .irradiance import decode
def resource():
data = bytearray(272)
data[:7] = b'\x89RW4xb2'
struct.pack_into('>I', data, 32, 1)
struct.pack_into('>I', data, 48, 64)
struct.pack_into('>6I', data, 64, 96, 0, 176, 0, 0, 0xEB0024)
struct.pack_into('>2I', data, 96, 1, 16)
values = [i / 4 for i in range(39)]
struct.pack_into('>39fI', data, 112, *values, 0xDEDEDEDE)
return data, values
class IrradianceTests(unittest.TestCase):
def test_preserves_coefficients_position_and_link_word(self):
data, values = resource()
groups = decode(data)
self.assertEqual(len(groups), 1)
self.assertEqual(struct.unpack('<39fI', groups[0][0]), (*values, 0xDEDEDEDE))
def test_rejects_records_outside_section(self):
data, _ = resource()
struct.pack_into('>I', data, 96, 2)
with self.assertRaises(ValueError):
decode(data)
def test_rejects_nonfinite_sh(self):
data, _ = resource()
struct.pack_into('>f', data, 112, float('nan'))
with self.assertRaises(ValueError):
decode(data)
@@ -0,0 +1,35 @@
import json
import struct
import tempfile
import unittest
from pathlib import Path
from .refresh_textures import refresh
class RefreshTests(unittest.TestCase):
def test_payload_refresh_preserves_tail_and_failed_refresh_preserves_output(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
string = lambda x: struct.pack('<I', len(x)) + x
prefix = b'SKATE14\0' + struct.pack('<I', 0x12345678) + string(b'fixture') + bytes(49*4)
prefix += struct.pack('<9I', 0, 1, 0, 0, 0, 0, 0, 0, 0)
old = bytes(16)
header = string(b'texture') + struct.pack('<3I', 2, 2, 0)
tail = b'geometry/collision/metadata-preservation-fixture'
source = root/'input.skate'
source.write_bytes(prefix + header + struct.pack('<II', 0, 16) + old + tail)
cache = bytes(range(16))
(root/'new.rgba').write_bytes(cache)
manifest = root/'manifest.json'
manifest.write_text(json.dumps({'textures': {'texture': dict(width=2, height=2, rgba='new.rgba')}}))
output = root/'out.skate'
report = refresh(manifest, source, output)
self.assertEqual(report['count'], 1)
self.assertEqual(output.read_bytes()[:len(prefix)], prefix)
self.assertEqual(output.read_bytes()[report['new_tail_offset']:], tail)
saved = output.read_bytes()
(root/'new.rgba').write_bytes(b'bad')
with self.assertRaises(ValueError): refresh(manifest, source, output)
self.assertEqual(output.read_bytes(), saved)
with self.assertRaises(ValueError): refresh(manifest, source, source)
self.assertFalse(list(root.glob('*.tmp')))
+36
View File
@@ -0,0 +1,36 @@
import struct
import unittest
import numpy as np
from .teleports import language_table, location_records
from .test_dynamic_props import resource
class TeleportTests(unittest.TestCase):
def test_language_join_uses_keys_not_storage_order(self):
# Offsets are relative to the chunk payload; strings need not follow
# the same physical order as the sorted hash index.
raw = bytearray(36 + 16 + 11)
struct.pack_into('<5I', raw, 0, 0x39000, len(raw)-8, 2, 28, 44)
struct.pack_into('<4I', raw, 36, 123, 6, 456, 0)
raw[52:] = b'First\0Last\0'
self.assertEqual(language_table(raw), {123: b'Last', 456: b'First'})
struct.pack_into('<I', raw, 40, 999)
with self.assertRaises(ValueError): language_table(raw)
def test_locator_preserves_heading_and_world_space_position(self):
payload = bytearray(165)
struct.pack_into('>5I', payload, 0, 1, 0, 0, 32, 160)
m = np.array([[0, 0, -1, 0], [0, 1, 0, 0], [1, 0, 0, 0], [350, 140, -725, 1]], dtype=float)
struct.pack_into('>16f', payload, 32, *m.ravel())
struct.pack_into('>I', payload, 136, 160)
payload[160:] = b'spot\0'
rows = location_records(resource(0xEB0009, payload))
self.assertEqual(rows[0]['locator'], 'spot')
np.testing.assert_array_equal(rows[0]['matrix'], m)
struct.pack_into('>I', payload, 136, 32)
with self.assertRaisesRegex(ValueError, 'overlaps'): location_records(resource(0xEB0009, payload))
def test_bad_record_count_is_rejected(self):
payload = bytearray(32)
struct.pack_into('>5I', payload, 0, 1, 0, 0, 32, 160)
with self.assertRaises(ValueError): location_records(resource(0xEB0009, payload))
@@ -0,0 +1,46 @@
"""Regression fixtures for padded Xbox tiles and BC colour palettes."""
import importlib.util
from pathlib import Path
import struct
import sys
import unittest
from unittest.mock import patch
UTT = Path(__file__).resolve().parents[1] / 'vendor/utt'
sys.path.insert(0, str(UTT))
import rx2_fast
# Exercise the standalone fallback as well as the NumPy implementation.
spec = importlib.util.spec_from_file_location('rx2_scalar_test', UTT / 'rx2_parser.py')
scalar = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'rx2_fast': None}):
spec.loader.exec_module(scalar)
class TextureDecodeTests(unittest.TestCase):
def test_padding_cannot_overwrite_visible_rows(self):
for width, pitch in ((w, p) for w in (4, 8, 16, 32) for p in (2, 4, 8, 16)):
with self.subTest(width=width, pitch=pitch):
units = max(32*32, 4096//pitch)
raw = bytearray(units*pitch)
for offset in range(units):
x = scalar._x360_tiled_x(offset, width, pitch)
y = scalar._x360_tiled_y(offset, width, pitch)
if x < width and y < 32:
raw[offset*pitch:offset*pitch+pitch] = bytes([1+y])*pitch
expected = b''.join(bytes([1+y])*(width*pitch) for y in range(32))
for decoder in (scalar, rx2_fast):
self.assertEqual(decoder._untile360(raw, width, pitch)[:len(expected)], expected)
def test_bc1_keeps_transparent_palette(self):
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
self.assertEqual(scalar._dxt1_block(colour, True), bytes(64))
self.assertEqual(rx2_fast._decode(colour, 4, 4, 1, False), bytes(64))
def test_bc2_bc3_use_four_colours_with_reversed_endpoints(self):
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
for kind, alpha, block_decoder in ((3, b'\xaa'*8, scalar._dxt3_block),
(5, b'\x00\xaa'+bytes(6), scalar._dxt5_block)):
expected = bytes((170, 0, 85, 170))*16
self.assertEqual(block_decoder(alpha+colour), expected)
self.assertEqual(rx2_fast._decode(alpha+colour, 4, 4, kind, False), expected)
@@ -315,6 +315,8 @@ def load_district_stream(
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 records and not stream_files:
return []
if not stream_files:
raise StreamFormatError(
f"{root} contains no c{stream_name}_*.xsf stream files"
+6 -2
View File
@@ -17,7 +17,9 @@ def _layout(units,width,pitch):
y=(((m//(aligned>>5))<<2)+((t>>(6+log))&1)+((b&2048)>>10))<<3
y+=(((t&(((pitch<<6)-1)&~31))+((t&15)<<1))>>(3+log))&~1
y+=(t&16)>>4
dest=y*width+x;valid=dest<units
# Padded tile columns are outside the logical image. Flattening them
# first aliases them onto later rows and overwrites valid blocks.
dest=y*width+x;valid=(x<width)&(dest<units)
# Match scalar last-write ordering even for narrow padded surfaces.
source=np.full(units,-1,dtype=np.int64)
np.maximum.at(source,dest[valid],offset[valid])
@@ -39,7 +41,9 @@ def _blocks(data,w,h,size):
def _colour(b,transparent):
c=b[:,:4].astype(np.uint32);c0=c[:,0]*256+c[:,1];c1=c[:,2]*256+c[:,3]
def rgb(v):return np.stack((((v>>11)&31)*255//31,((v>>5)&63)*255//63,(v&31)*255//31),axis=1)
a=rgb(c0);z=rgb(c1);four=c0>c1
# BC2/BC3 always use four colours. Only BC1 uses endpoint ordering
# to select the three-colour + transparent-black palette.
a=rgb(c0);z=rgb(c1);four=(c0>c1) | (not transparent)
pal=np.zeros((len(b),4,4),dtype=np.uint8);pal[:,:,3]=255
pal[:,0,:3]=a;pal[:,1,:3]=z
pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2)
+3 -2
View File
@@ -426,7 +426,7 @@ def _untile360(src, 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):
if x < width_units and 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)
@@ -473,7 +473,8 @@ 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:
# DXT3/DXT5 have independent alpha and always use four colour entries.
if c0 > c1 or not transparent:
c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F,
(c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F,
c0 & 0x1F, c1 & 0x1F)