Connect authored and retail map splines to live grind queries

This commit is contained in:
chazmm committed 2026-09-07 18:59:35 +01:00
1 parent 2c2ba09877
commit 411a0f652d
7 files changed
+180 -84

No files matched your search

+18 -3
View File
@@ -33,13 +33,28 @@ pub fn fifty_fifty_candidate(
board: [V; 4],
balance: [f32; 2],
contacts: [Option<TruckContact>; 2],
) -> Option<FiftyFiftyCandidate> {
fifty_fifty_candidate_on_splines(board, balance, contacts, &[])
}
///82D89150 also accepts two segment indices when their hits pass its signed
/// cross-rail separation check. Splines with multiple segments need this path.
pub fn fifty_fifty_candidate_on_splines(
board: [V; 4], balance: [f32; 2],
contacts: [Option<TruckContact>; 2], primitives: &[Primitive],
) -> Option<FiftyFiftyCandidate> {
let [Some(front), Some(rear)] = contacts else { return None };
if balance[0].abs().max(balance[1].abs()) >= 0.9
|| front.primitive != rear.primitive
{
if balance[0].abs().max(balance[1].abs()) >= 0.9 {
return None;
}
if front.primitive != rear.primitive {
let edge = primitives.get(front.primitive)?;
let delta = sub(edge.end, edge.start);
let direction = scale(delta, dot3(delta, delta).sqrt().recip());
let across = cross(upright_normal(direction), direction);
//820641A8. This comparison is signed in the original routine.
if dot3(sub(front.position, rear.position), across) > 0.1 { return None; }
}
let front_depth = dot3(sub(board[3], front.position), board[1]);
let rear_depth = dot3(sub(board[3], rear.position), board[1]);
if front_depth.max(rear_depth) >= 0.13 {
+5 -38
View File
@@ -99,43 +99,10 @@ fn box_faces(out: &mut Vec<[Vector3; 4]>, min: Vector3, max: Vector3) {
v(max.x, min.y, min.z), v(min.x, min.y, min.z)]);
}
/// Relocatable Pegasus tSplineData, matching the project's established native
/// builder (owned/world/src/grind_spline.cpp). One straight segment per rail.
/// Guest pointers stay blob-relative; the Rust owner retains stable rail IDs.
pub(crate) fn spline_blob() -> Vec<u8> {
let rails = rails();
let segments = 16 + rails.len() * 32;
let mut bytes = vec![0; segments + rails.len() * 144];
word(&mut bytes, 0, rails.len() as u32);
word(&mut bytes, 4, rails.len() as u32);
word(&mut bytes, 8, 16);
word(&mut bytes, 12, segments as u32);
for (i, rail) in rails.iter().enumerate() {
let r = 16 + i * 32;
let s = segments + i * 144;
bytes[r..r + 8].copy_from_slice(&rail.id.to_be_bytes());
bytes[r + 8..r + 16].copy_from_slice(&0x2c7017070007004au64.to_be_bytes());
word(&mut bytes, r + 20, s as u32);
word(&mut bytes, r + 24, s as u32);
let delta = [rail.end.x - rail.start.x, rail.end.y - rail.start.y,
rail.end.z - rail.start.z];
let length = delta.iter().map(|v| v * v).sum::<f32>().sqrt();
vector(&mut bytes, s, [delta[0], delta[1], delta[2], 0.]);
vector(&mut bytes, s + 48, [rail.start.x, rail.start.y, rail.start.z, 1.]);
vector(&mut bytes, s + 64, [1. / length, 0., 0., 0.]);
vector(&mut bytes, s + 80, [rail.start.x.min(rail.end.x),
rail.start.y.min(rail.end.y), rail.start.z.min(rail.end.z), 0.]);
vector(&mut bytes, s + 96, [rail.start.x.max(rail.end.x),
rail.start.y.max(rail.end.y), rail.start.z.max(rail.end.z), 0.]);
word(&mut bytes, s + 112, length.to_bits());
word(&mut bytes, s + 120, r as u32);
}
bytes
}
mod spline;
pub(crate) use spline::primitives;
fn word(bytes: &mut [u8], at: usize, value: u32) {
bytes[at..at + 4].copy_from_slice(&value.to_be_bytes());
}
fn vector(bytes: &mut [u8], at: usize, value: [f32; 4]) {
for (i, v) in value.into_iter().enumerate() { word(bytes, at + i * 4, v.to_bits()); }
/// Relocatable Pegasus tSplineData used by the live default-world query.
pub(crate) fn spline_blob() -> Vec<u8> {
spline::build(None).expect("static test-course splines are valid")
}
+114
View File
@@ -0,0 +1,114 @@
//! SK8R15 owned/world/src/grind_spline.cpp and TU3 AssetRecord82C1E568.
//! Keep the native cubic payload; the game's contact primitive is its chord
//! D -> (A+B)+(C+D), including for retail cubic segments.
use skate_core::physics::grind_contact::Primitive;
use skate_data::skate_map::{Rail, SkateMap};
pub(super) fn build(map: Option<&SkateMap>) -> Result<Vec<u8>, String> {
let defaults;
let rails = if let Some(map) = map { &map.rails } else {
defaults = super::rails().iter().map(|r| Rail {
name: r.name.into(), closed: false, native: None,
points: vec![[r.start.x,r.start.y,r.start.z], [r.end.x,r.end.y,r.end.z]],
}).collect();
&defaults
};
let mut records = Vec::new();
for rail in rails {
let mut header = [0u32; 6];
let mut segments = Vec::new();
if let Some(raw) = &rail.native {
if raw.len() < 28 { return Err(format!("Truncated native rail {}", rail.name)); }
// .skate is little endian; Pegasus blobs are big endian.
let words: Vec<u32> = raw.chunks_exact(4)
.map(|w| u32::from_le_bytes(w.try_into().unwrap())).collect();
header.copy_from_slice(&words[..6]);
// u64 fields use low/high words in the package.
header.swap(0,1); header.swap(2,3);
if words[6] as usize != (raw.len()-28)/120 || (raw.len()-28)%120 != 0 {
return Err(format!("Invalid native rail segment count: {}", rail.name));
}
for segment in words[7..].chunks_exact(30) {
segments.push(<[u32;30]>::try_from(segment).unwrap());
}
} else {
if rail.points.len() < 2 { return Err(format!("Rail {} needs two points", rail.name)); }
let mut id = 1469598103934665603u64;
for byte in rail.name.bytes().chain(rail.points.iter().flat_map(|p|
p.iter().flat_map(|f| f.to_bits().to_be_bytes()))) {
id = (id ^ byte as u64).wrapping_mul(1099511628211);
}
header = [(id>>32) as u32,id as u32,0x2c701707,0x0007004a,0,0];
let mut points = rail.points.clone();
let first = points[0]; let last = *points.last().unwrap();
let distance: f32 = (0..3).map(|i| (first[i]-last[i]).powi(2)).sum();
if rail.closed && distance > 0.001*0.001 { points.push(first); }
for pair in points.windows(2) {
let [start,end] = [pair[0],pair[1]];
let mut segment = [0u32;30];
for i in 0..3 {
let delta = end[i]-start[i];
segment[i] = (-2.*delta).to_bits();
segment[4+i] = (3.*delta).to_bits();
segment[12+i] = start[i].to_bits();
segment[20+i] = start[i].min(end[i]).to_bits();
segment[24+i] = start[i].max(end[i]).to_bits();
}
segment[15] = 1f32.to_bits();
segments.push(segment);
}
}
if segments.is_empty() { return Err(format!("Rail {} has no segments",rail.name)); }
for s in &segments {
if s[..28].iter().any(|w| !f32::from_bits(*w).is_finite()) {
return Err(format!("Non-finite spline data in {}",rail.name));
}
}
records.push((header,segments));
}
let count: usize = records.iter().map(|r| r.1.len()).sum();
let base = 16+records.len()*32;
let mut bytes = vec![0; base+count*144];
put(&mut bytes,0,records.len() as u32); put(&mut bytes,4,count as u32);
put(&mut bytes,8,16); put(&mut bytes,12,base as u32);
let mut at = base;
for (index,(header,segments)) in records.iter().enumerate() {
let r = 16+index*32;
for i in 0..5 { put(&mut bytes,r+i*4,header[i]); }
put(&mut bytes,r+20,at as u32);
put(&mut bytes,r+24,(at+(segments.len()-1)*144) as u32);
put(&mut bytes,r+28,header[5]);
for (i,segment) in segments.iter().enumerate() {
for (j,w) in segment.iter().enumerate() { put(&mut bytes,at+j*4,*w); }
put(&mut bytes,at+120,r as u32);
if i>0 { put(&mut bytes,at+124,(at-144) as u32); }
if i+1<segments.len() { put(&mut bytes,at+128,(at+144) as u32); }
at+=144;
}
}
Ok(bytes)
}
pub(crate) fn primitives(map: Option<&SkateMap>) -> Result<Vec<Primitive>,String> {
let bytes = build(map)?;
let word = |at| u32::from_be_bytes(bytes[at..at+4].try_into().unwrap());
let mut result = Vec::new();
let base = word(12) as usize;
for i in 0..word(4) as usize {
let s = base+i*144;
let r = word(s+120) as usize;
let coefficients: [[f32;4];4] = std::array::from_fn(|v|
std::array::from_fn(|j| f32::from_bits(word(s+v*16+j*4))));
let [a,b,c,d] = coefficients;
let end = std::array::from_fn(|j| (a[j]+b[j])+(c[j]+d[j]));
let length: f32 = (0..3).map(|j| (end[j]-d[j]).powi(2)).sum();
if !length.is_finite() || length <= 0.000001 {
return Err(format!("Spline segment {i} has a degenerate contact chord"));
}
result.push(Primitive { start:d,end,owner:((word(r) as u64)<<32)|word(r+4) as u64 });
}
bevy::log::info!("GRIND_RUNTIME splines={} primitives={} source={}",word(0),result.len(),
map.map(|m|m.name.as_str()).unwrap_or("default test world"));
Ok(result)
}
fn put(bytes:&mut [u8],at:usize,w:u32) { bytes[at..at+4].copy_from_slice(&w.to_be_bytes()); }
+1 -1
View File
@@ -146,7 +146,7 @@ impl GamePhysics {
let (query, retention) = ground::query_settings();
Ok(Self {
clock: clock::SimulationClock::default(),
grind: grind::Runtime::load(&data, map.is_none())?,
grind: grind::Runtime::load(&data, map)?,
board,
riding,
world,
+33 -33
View File
@@ -32,25 +32,15 @@ pub(crate) struct Runtime {
pub launch_velocity: V,
cooldown: u32,
previous_position: V,
diagnostic_tick: u32,
}
impl Runtime {
pub fn load(data: &Collections, test_world: bool) -> Result<Self, String> {
pub fn load(data: &Collections, map: Option<&skate_data::skate_map::SkateMap>) -> Result<Self, String> {
let graph = data
.words::<8>("physics_grinds", "default", "PinVsSlope")?
.map(f32::from_bits);
Ok(Self {
primitives: if test_world {
crate::grind_world::rails()
.iter()
.map(|r| Primitive {
start: lanes(r.start),
end: lanes(r.end),
owner: r.id,
})
.collect()
} else {
Vec::new()
},
primitives: crate::grind_world::primitives(map)?,
candidate: None,
name: String::new(),
active: false,
@@ -77,6 +67,7 @@ impl Runtime {
cooldown: 0,
distance: 0.0,
previous_position: [0.; 4],
diagnostic_tick: 0,
})
}
pub fn output(&self) -> GrindState {
@@ -107,25 +98,36 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) {
runtime.manager_age = (runtime.manager_age + 0.0035).min(1.0);
let old = runtime.candidate;
runtime.candidate = None;
if runtime.cooldown > 0 || skater.player_input.grind.disabled || p.flags_2476 & 0x01000000 != 0
{
return;
}
let hits = grind_contact::truck_contacts(
board,
p.flags_2484,
runtime.truck_to_wheel,
runtime.deck_to_truck,
runtime.diagnostic_tick = runtime.diagnostic_tick.wrapping_add(1);
let gated = runtime.cooldown > 0 || skater.player_input.grind.disabled
|| p.flags_2476 & 0x01000000 != 0;
// Native GrindData query82D8A828: a 1.2m half-extent, at most40 chords.
let nearby: Vec<usize> = runtime.primitives.iter().enumerate().filter(|(_,edge)|
(0..3).all(|i| edge.start[i].min(edge.end[i]) <= board[3][i]+1.2
&& edge.start[i].max(edge.end[i]) >= board[3][i]-1.2))
.map(|(i,_)|i).take(40).collect();
if nearby.is_empty() { return; }
let edges: Vec<Primitive> = nearby.iter().map(|i|runtime.primitives[*i]).collect();
let mut hits = grind_contact::truck_contacts(
board, p.flags_2484, runtime.truck_to_wheel, runtime.deck_to_truck, &edges,
);
for hit in hits.iter_mut().flatten() { hit.primitive = nearby[hit.primitive]; }
let extra = &skater.animation_input.extra;
let candidate = grind_contact::fifty_fifty_candidate_on_splines(
board, [extra.grind_translation, extra.grind_stability_nudge], hits,
&runtime.primitives,
);
let extra = &skater.animation_input.extra;
let Some(candidate) = grind_contact::fifty_fifty_candidate(
board,
[extra.grind_translation, extra.grind_stability_nudge],
hits,
) else {
return;
};
// Passive diagnostics for the user's visual run; no input simulation.
if runtime.diagnostic_tick % 30 == 0 || (!gated && candidate.is_some()) != old.is_some() {
bevy::log::info!("GRIND_QUERY state={} gated={} nearby={} hits={:?} depth={:?} candidate={} board={:?} up={:?} forward={:?} probe={:?} flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}",
p.state_2508, gated, nearby.len(), hits.map(|h|h.map(|h|h.primitive)),
hits.map(|h|h.map(|h|dot3(sub(board[3],h.position),board[1]))),
candidate.is_some(),board[3],board[1],board[2],
[runtime.truck_to_wheel,runtime.deck_to_truck],
p.flags_2468,p.flags_2472,p.flags_2476,p.flags_2480,p.flags_2484);
}
if gated { return; }
let Some(candidate) = candidate else { return; };
let velocity = p.vectors_400_416[0].map(f32::from_bits);
//82D886B8: air accepts an actual intersection; same active grind uses90deg.
//The ordinary level-ground admission uses17deg.
@@ -311,9 +313,7 @@ fn append(physics: &mut GamePhysics, force: V) -> Result<(), String> {
}
Ok(())
}
fn lanes(v: Vector3) -> V {
[v.x, v.y, v.z, 0.]
}
fn xyz(v: V) -> Vector3 {
Vector3::new(v[0], v[1], v[2])
}
-6
View File
@@ -69,12 +69,6 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> {
.into(),
);
}
if !map.rails.is_empty() {
eprintln!(
"SKATE LIMITATION: {} rail records parsed, but supplied game has no nonempty grind-edge controller. Rails will not engage grinds.",
map.rails.len()
);
}
if !map.routes.is_empty() {
eprintln!(
"SKATE LIMITATION: {} NPC routes parsed; supplied game has no NPC controller.",
+9 -3
View File
@@ -1,13 +1,13 @@
# Live grind integration
The default world now supplies seven straight grind paths shared with rail/coping collision geometry. This pass connects the paired-truck 50-50 acquisition path to the production state selector, board forces, skeleton update, stock grind animations and physical output. Imported map grind descriptors are not wired into this owner yet.
The default world now supplies seven straight grind paths shared with rail/coping collision geometry. This pass connects the paired-truck 50-50 acquisition path to the production state selector, board forces, skeleton update, stock grind animations and physical output. The runtime now builds and consumes the same Pegasus spline data for default-world and imported `.skate` rails (authored polylines, closed paths, and preserved retail cubic records).
## Source and implementation
TU3 image: default_82000000_011B0000.bin, SHA256 f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4. Analysis used the local generated PPC and the copied IDA database; the reference game was not launched for this implementation.
- 82C1FDC0 and 82D89150: actual truck probe rectangles, paired same-primitive contacts, depth and balance rejection. No nearest-distance snap or positional teleport.
- 82D886B8: air and ordinary level-ground admission. The host currently covers straight level rails; sloped approach classification, adjacency arbitration, tipslides, boardslides and darkslides still need their own live adapters.
- 82C1FDC0 and 82D89150: actual truck probe rectangles, paired contacts including the native cross-segment separation branch, depth and balance rejection. No nearest-distance snap or positional teleport.
- 82D886B8: air and ordinary level-ground admission. The host currently covers straight level rails; sloped approach classification, tipslides, boardslides and darkslides still need their own live adapters.
- 82D41D70 / 82D3FA18 / 82D3FD88: 50-50 lateral pin and friction, using the stock PinVsSlope and FrictionVsTime curves. Authored static rail support uses the upward normal and default surface multiplier.
- 82D41E38 / 82D40890: rail-aligned target blended through the existing native matrix interpolation and angular-only board drive. User-driven grind twist forces and the remaining grind-manager transition scoring are not complete.
- 82D73AB0: straight static 50-50 pop branch, stock physics-mode launch speeds, lateral nudge and launch velocity publication.
@@ -20,3 +20,9 @@ TU3 image: default_82000000_011B0000.bin, SHA256 f4aa113eb541bfba03dbc108cf5ab43
Build with Build.ps1; launch bin/skate-game.exe --assets assets (no map argument). The low straight rail is at x=-7, z=7..15, 0.45m above the lower floor; the higher rail is at x=4.5, z=8..16, 0.70m above it. Approach along the rail and land both trucks on its top. The halfpipe coping and three starting-platform edges also have authored paths.
Build completed. No automated tests or controller-driven gameplay tests were run. Visual behavior, catch consistency, pop timing, naming and solver stability while grinding require the user's playtest; this is not a claim of complete Skate 3 grind parity.
## Spline integration correction (2026-09-07)
The newer `SK8R15/Source/owned/world/src/grind_spline.cpp` revealed that the previous default-world blob incorrectly put delta in coefficient A and populated auxiliary words as length fields. Authored segments now use A=-2*delta, B=3*delta, C=0, D=start. The old independent endpoint list bypassed that blob, so this malformed blob alone does not explain the failed default-world catches.
Both map paths now build Pegasus data and feed the live query from it. TU3 AssetRecord82C1E568 constructs exactly one contact chord per cubic, D to (A+B)+(C+D); curved payloads are retained without an invented tessellation scheme. Imported native IDs, flags and all30 payload words are preserved. The query uses the native local1.2m bounds and40-entry limit. The latest user run recorded no entered grind; the default-world acquisition failure still needs a near-rail observation to identify the rejecting gate/contact. Passive GRIND_QUERY logging now records those observations during the user's visual run. No gameplay success is claimed from a build.