mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Extract original travel destinations and add teleport menu with still-water rendering
This commit is contained in:
1 parent
79ea829ca9
commit
38d4f30ef0
17 files changed
+497
-32
No files matched your search
@@ -9,6 +9,7 @@ pub(crate) struct Config {
|
||||
pub map_path: Option<PathBuf>,
|
||||
pub difficulty: crate::difficulty::Difficulty,
|
||||
pub check_assets: bool,
|
||||
pub teleport: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -20,6 +21,7 @@ impl Config {
|
||||
map_path: None,
|
||||
difficulty: crate::difficulty::Difficulty::default(),
|
||||
check_assets: false,
|
||||
teleport: None,
|
||||
};
|
||||
let mut difficulty_override = None;
|
||||
let mut explicit_map = false;
|
||||
@@ -37,6 +39,7 @@ impl Config {
|
||||
}
|
||||
Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; }
|
||||
Some("--check-assets") => config.check_assets = 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())?);
|
||||
@@ -69,6 +72,13 @@ impl Config {
|
||||
config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?);
|
||||
}
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -84,6 +84,8 @@ pub(crate) struct Menu {
|
||||
status: String,
|
||||
maps: Vec<crate::map_library::Entry>,
|
||||
selected_map: usize,
|
||||
destinations: Vec<crate::teleport_menu::Destination>,
|
||||
travel_page: Option<usize>,
|
||||
}
|
||||
pub(crate) fn gameplay_active(menu: Option<Res<Menu>>) -> bool {
|
||||
menu.is_none_or(|m| !m.open)
|
||||
@@ -117,6 +119,7 @@ fn setup(
|
||||
mut window: Single<&mut Window, With<PrimaryWindow>>,
|
||||
cameras: Query<Entity, With<Camera3d>>,
|
||||
adapter: Res<RenderAdapter>,
|
||||
mut skater: ResMut<crate::physics::SkaterRuntime>,
|
||||
) {
|
||||
let path = config
|
||||
.asset_root
|
||||
@@ -194,23 +197,32 @@ fn setup(
|
||||
display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center,
|
||||
justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default()
|
||||
}, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| {
|
||||
root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() },
|
||||
root.spawn((Node { width:px(720),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() },
|
||||
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
||||
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
||||
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
||||
for i in 0..10 {
|
||||
panel.spawn((MenuLabel(usize::MAX),Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
||||
panel.spawn((MenuLabel(usize::MAX-1),Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
||||
for i in 0..11 {
|
||||
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
|
||||
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
|
||||
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
||||
});
|
||||
}
|
||||
panel.spawn((StatusLabel,Text::new(""),TextFont {font_size:15.,..default()},TextColor(Color::srgb(0.65,0.75,0.8))));
|
||||
panel.spawn((Text::new("Click to cycle | Up/Down select | Left/Right change\nEsc resume | Changes save automatically"),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.65,0.75,0.8))));
|
||||
panel.spawn((MenuLabel(usize::MAX-2),Text::new(""),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.65,0.75,0.8))));
|
||||
});
|
||||
});
|
||||
commands.insert_resource(SceneTarget(target));
|
||||
let maps = crate::map_library::discover(&config.asset_root);
|
||||
let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0);
|
||||
let (destinations, mut status) = match crate::teleport_menu::load(&config.asset_root) {
|
||||
Ok(locations) => (locations, String::new()),
|
||||
Err(e) => (Vec::new(), e),
|
||||
};
|
||||
if let Some(id) = &config.teleport {
|
||||
if let Some(target) = destinations.iter().find(|d| &d.id == id).and_then(|d| d.matrix) {
|
||||
if let Err(e) = skater.travel_to(target) { status = e; }
|
||||
}
|
||||
}
|
||||
commands.insert_resource(Menu {
|
||||
open: false,
|
||||
selected: 0,
|
||||
@@ -218,9 +230,11 @@ fn setup(
|
||||
path,
|
||||
supported_msaa,
|
||||
difficulty: config.difficulty,
|
||||
status: String::new(),
|
||||
status,
|
||||
maps,
|
||||
selected_map,
|
||||
destinations,
|
||||
travel_page: None,
|
||||
});
|
||||
}
|
||||
fn msaa(samples: u32) -> Msaa {
|
||||
@@ -238,6 +252,7 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
|
||||
fn interact(
|
||||
config: Res<crate::config::Config>,
|
||||
mut physics: ResMut<crate::physics::GamePhysics>,
|
||||
mut skater: ResMut<crate::physics::SkaterRuntime>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut menu: ResMut<Menu>,
|
||||
mut time: ResMut<Time<Virtual>>,
|
||||
@@ -245,20 +260,25 @@ fn interact(
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
menu.open = !menu.open;
|
||||
if menu.open && menu.travel_page.is_some() {
|
||||
menu.travel_page = None;
|
||||
menu.selected = 8;
|
||||
} else { menu.open = !menu.open; }
|
||||
}
|
||||
let mut action = None;
|
||||
if menu.open {
|
||||
if keys.just_pressed(KeyCode::ArrowUp) {
|
||||
menu.selected = (menu.selected + 9) % 10;
|
||||
menu.selected = (menu.selected + 10) % 11;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowDown) {
|
||||
menu.selected = (menu.selected + 1) % 10;
|
||||
menu.selected = (menu.selected + 1) % 11;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowLeft) {
|
||||
if menu.travel_page.is_some() && (keys.just_pressed(KeyCode::ArrowLeft) || keys.just_pressed(KeyCode::ArrowRight)) {
|
||||
action = Some((if keys.just_pressed(KeyCode::ArrowLeft) { 8 } else { 9 }, 1));
|
||||
} else if keys.just_pressed(KeyCode::ArrowLeft) {
|
||||
action = Some((menu.selected, -1));
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) {
|
||||
if (menu.travel_page.is_none() && keys.just_pressed(KeyCode::ArrowRight)) || keys.just_pressed(KeyCode::Enter) {
|
||||
action = Some((menu.selected, 1));
|
||||
}
|
||||
for (interaction, row) in &buttons {
|
||||
@@ -269,6 +289,30 @@ fn interact(
|
||||
}
|
||||
}
|
||||
if let Some((row, direction)) = action {
|
||||
if let Some(page) = menu.travel_page {
|
||||
match row {
|
||||
0..=7 => {
|
||||
if let Some(destination) = menu.destinations.get(page * 8 + row).cloned() {
|
||||
if let Some(transform) = destination.matrix {
|
||||
if config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &destination.map)) {
|
||||
match skater.travel_to(transform) {
|
||||
Ok(()) => { menu.open = false; menu.travel_page = None; menu.status = format!("Arrived at {}", destination.name); }
|
||||
Err(e) => menu.status = e,
|
||||
}
|
||||
} else if let Some(entry) = menu.maps.iter().find(|m| m.path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &destination.map))) {
|
||||
match crate::map_library::switch_to(&config.asset_root, entry, Some(&destination.id)) {
|
||||
Ok(()) => { exit.write(AppExit::Success); }
|
||||
Err(e) => menu.status = e,
|
||||
}
|
||||
} else { menu.status = format!("{} is not installed", destination.map); }
|
||||
} else { menu.status = destination.unavailable_reason.unwrap_or_else(|| "Destination unavailable".into()); }
|
||||
}
|
||||
}
|
||||
8 => menu.travel_page = Some(page.saturating_sub(1)),
|
||||
9 => menu.travel_page = Some((page + 1).min(menu.destinations.len().saturating_sub(1) / 8)),
|
||||
_ => { menu.travel_page = None; menu.selected = 8; }
|
||||
}
|
||||
} else {
|
||||
match row {
|
||||
0 => {
|
||||
let size = cycle(
|
||||
@@ -304,8 +348,9 @@ fn interact(
|
||||
Err(e) => menu.status = e,
|
||||
}
|
||||
}
|
||||
8 => menu.open = false,
|
||||
9 => {
|
||||
8 => { menu.travel_page = Some(0); menu.selected = 0; menu.status = "Select a destination | Left/Right: pages | Esc: back".into(); },
|
||||
9 => menu.open = false,
|
||||
10 => {
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
_ => {}
|
||||
@@ -324,6 +369,7 @@ fn interact(
|
||||
Err(e) => format!("Could not save: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if menu.open {
|
||||
time.pause();
|
||||
@@ -397,6 +443,26 @@ fn labels(
|
||||
let s = &menu.settings;
|
||||
let size = s.internal_size(window.physical_size());
|
||||
for (label, mut text) in &mut labels {
|
||||
if label.0 >= usize::MAX-2 {
|
||||
let travel = menu.travel_page.is_some();
|
||||
**text = if label.0 == usize::MAX {
|
||||
if travel { "TELEPORT" } else { "PAUSED" }
|
||||
} else if label.0 == usize::MAX-1 {
|
||||
if travel { "ORIGINAL GAME LOCATIONS" } else { "GAMEPLAY & GRAPHICS" }
|
||||
} else if travel {
|
||||
"Click or Enter to travel | Up/Down select | Left/Right pages\nEsc back | Travelling to another map restarts the session"
|
||||
} else { "Click to cycle | Up/Down select | Left/Right change\nEsc resume | Changes save automatically" }.into();
|
||||
continue;
|
||||
}
|
||||
if let Some(page) = menu.travel_page {
|
||||
**text = match label.0 {
|
||||
0..=7 => menu.destinations.get(page * 8 + label.0).map(|d| format!("{} — {}{}", d.name, d.map, if d.matrix.is_none() { " (unavailable)" } else { "" })).unwrap_or_default(),
|
||||
8 => "Previous page".into(),
|
||||
9 => format!("Next page ({}/{})", page + 1, menu.destinations.len().div_ceil(8).max(1)),
|
||||
_ => "Back to pause menu".into(),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
**text = match label.0 {
|
||||
0 => format!("Resolution {} x {}", s.width, s.height),
|
||||
1 => format!(
|
||||
@@ -423,7 +489,8 @@ fn labels(
|
||||
5 => format!("Difficulty {}", menu.difficulty.label()),
|
||||
6 => format!("Map {}", menu.maps[menu.selected_map].label),
|
||||
7 => "Load map (restarts session)".into(),
|
||||
8 => "Resume".into(),
|
||||
8 => "Teleport…".into(),
|
||||
9 => "Resume".into(),
|
||||
_ => "Quit game".into(),
|
||||
};
|
||||
}
|
||||
@@ -462,7 +529,7 @@ mod tests {
|
||||
.insert_resource(Menu {
|
||||
open: false, selected: 0, settings: GraphicsSettings::default(),
|
||||
difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(),
|
||||
maps: Vec::new(), selected_map: 0,
|
||||
maps: Vec::new(), selected_map: 0, destinations: Vec::new(), travel_page: None,
|
||||
})
|
||||
.add_systems(Update, apply);
|
||||
app.world_mut().spawn((Window::default(), PrimaryWindow));
|
||||
|
||||
@@ -15,6 +15,7 @@ mod skater_animation;
|
||||
mod verification;
|
||||
mod performance;
|
||||
mod graphics_menu;
|
||||
mod teleport_menu;
|
||||
mod render_capacity;
|
||||
mod retail_render;
|
||||
mod retail_character;
|
||||
|
||||
@@ -46,12 +46,17 @@ pub(crate) fn default_map(assets: &Path) -> Result<Option<PathBuf>, String> {
|
||||
}
|
||||
|
||||
pub(crate) fn switch(assets: &Path, entry: &Entry) -> Result<(), String> {
|
||||
switch_to(assets, entry, None)
|
||||
}
|
||||
|
||||
pub(crate) fn switch_to(assets: &Path, entry: &Entry, destination: Option<&str>) -> Result<(), String> {
|
||||
if let Some(path) = &entry.path {
|
||||
let map = skate_data::skate_map::SkateMap::load(path)?;
|
||||
crate::skate_world::validate_runtime(&map)?;
|
||||
}
|
||||
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
|
||||
command.arg("--assets").arg(assets);
|
||||
if let Some(id) = destination { command.arg("--teleport").arg(id); }
|
||||
if let Some(path) = &entry.path {
|
||||
command.arg("--map").arg(path);
|
||||
} else {
|
||||
|
||||
@@ -82,6 +82,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.set_checkpoint(super::teleport_state::Checkpoint { transform, on_board: true });
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(
|
||||
asset_root: &Path,
|
||||
graphs: &StockGraphs,
|
||||
|
||||
@@ -24,6 +24,10 @@ pub(crate) struct Runtime {
|
||||
pending_reply: Option<Target>,
|
||||
}
|
||||
impl Runtime {
|
||||
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(),
|
||||
|
||||
@@ -72,11 +72,15 @@ fn load(config: Res<crate::config::Config>, mut settings: ResMut<Settings>) {
|
||||
info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode");
|
||||
return;
|
||||
}
|
||||
let data = std::fs::read(config.asset_root.join("private/exposure.json"));
|
||||
if let Some(a) = data
|
||||
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())
|
||||
{
|
||||
.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())
|
||||
|
||||
@@ -208,7 +208,10 @@ impl Definition {
|
||||
let mut r = Reader(bytes);
|
||||
r.take(16)?;
|
||||
let shader = r.text()?;
|
||||
let family = r.u32()?;
|
||||
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()? {
|
||||
@@ -258,6 +261,7 @@ impl Definition {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -281,7 +285,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(if self.family == 31 { "normal2" } else { "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);
|
||||
@@ -310,7 +314,7 @@ impl Definition {
|
||||
(_, 2) => AlphaMode::Blend,
|
||||
_ => AlphaMode::Opaque,
|
||||
};
|
||||
let alpha = if self.family == 32 || (self.family == 30 && self.shader.ends_with("alpha")) {
|
||||
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];
|
||||
|
||||
@@ -132,29 +132,68 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
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 {
|
||||
} 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 uv1=raw_uv*p.water[2].xy+p.water[1].xy*t;
|
||||
let uv2=raw_uv*p.water[2].zw+p.water[1].zw*t;
|
||||
let n1=textureSample(normal_map,normal_sampler,vec2<f32>(uv1.x,1.0-uv1.y)).rgb;
|
||||
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(uv2.x,1.0-uv2.y)).rgb;
|
||||
let vn=normalize((2.0*n1+2.0*n2-2.0)*p.water[0].xzw);
|
||||
let water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn);
|
||||
let wlm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*vn.xz*vec2<f32>(1.0,-1.0),0.0).rgb;
|
||||
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 {
|
||||
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+frame_state.shadow.rgb); break;
|
||||
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,i.uv).xz-p.water[3].y); }
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Travel menu and still-water follow-up
|
||||
|
||||
## Travel
|
||||
|
||||
Esc → Teleport lists the original English frontend location names. 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 visible but 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. Cross-map travel uses the existing map-switch process
|
||||
with `--teleport ID`; startup checks that the destination belongs to that map.
|
||||
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.
|
||||
@@ -205,6 +205,9 @@ def install(iso,base,game_exe,report,game_root=None):
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,15 @@ 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 = {}
|
||||
@@ -26,4 +35,15 @@ def convert(assets, converted):
|
||||
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)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
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):
|
||||
@@ -14,6 +15,14 @@ def row(cls, key, parent='', **fields):
|
||||
|
||||
|
||||
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',
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user