diff --git a/crates/skate-data/examples/hud_data.rs b/crates/skate-data/examples/hud_data.rs index 5c26120c..fdacfb4a 100644 --- a/crates/skate-data/examples/hud_data.rs +++ b/crates/skate-data/examples/hud_data.rs @@ -37,6 +37,7 @@ fn main() -> Result<(), String> { let shapes: apt_scene::Shapes = serde_json::from_value(json["shapes"].clone()).map_err(|e| e.to_string())?; let mut max_draws = 0; + let mut saw_native_shadow_pair = false; for tick in 0..1800 { let new_trick = tick % 180 == 10; if new_trick { @@ -52,15 +53,23 @@ fn main() -> Result<(), String> { input.line_score += 100; input.clean = tick % 360 == 10; input.sketchy = !input.clean; - input.line_time = 400.0; + input.line_time = 8.0; input.multiplier = if input.multiplier == 1.5 { 2.0 } else { 1.5 }; } - input.line_time = (input.line_time - 1.0).max(0.0); + input.line_time = (input.line_time - 1.0 / 60.0).max(0.0); + input.sequence_timer = input.line_time as i32; runtime .update(input.clone(), new_trick, false, tick % 180 == 120) .map_err(|e| format!("Data frame {tick}: {e}"))?; let draws = apt_scene::draw(&runtime.bindings.movie, &runtime.vm, &shapes)?; max_draws = max_draws.max(draws.len()); + saw_native_shadow_pair |= draws.windows(2).any(|pair| { + pair[0].texture.contains("futurashadow") + && pair[0].multiply[..3] == [0., 0., 0.] + && pair[1].texture.contains("futuraheavy") + && pair[1].multiply[0] > 0. + }); + if draws .iter() .flat_map(|d| &d.vertices) @@ -69,6 +78,9 @@ fn main() -> Result<(), String> { return Err("Nonfinite authored draw geometry".into()); } } + if !saw_native_shadow_pair { + return Err("Missing native black shadow / heavy foreground pair".into()); + } println!( "Original HUD action audit passed 1800 authored frames; {} object slots, {} display instances", runtime.vm.objects.len(), diff --git a/crates/skate-data/examples/scoring_flow_data.rs b/crates/skate-data/examples/scoring_flow_data.rs index fa8dc4b0..a305427c 100644 --- a/crates/skate-data/examples/scoring_flow_data.rs +++ b/crates/skate-data/examples/scoring_flow_data.rs @@ -41,12 +41,30 @@ fn frame( reverting: false, } } +fn publish_hud( + scoring: &scoring_runtime::Runtime, + hud: &mut hud_runtime::Runtime, +) -> Result<(), String> { + hud.update( + scoring.hud_input(), + scoring.new_trick, + scoring.modified_trick, + scoring.close_tricks, + ) +} fn main() -> Result<(), String> { let root = std::env::args_os() .nth(1) .ok_or("Expected owned assets directory")?; let data = skate_data::collections::Collections::load(std::path::Path::new(&root))?; let mut scoring = scoring_runtime::Runtime::load(&data)?; + let movie_path = std::env::args_os() + .nth(2) + .ok_or("Expected owned trickdisplay.json")?; + let json: serde_json::Value = + serde_json::from_slice(&std::fs::read(movie_path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut hud = hud_runtime::Runtime::load(&json, scoring.hud_input())?; let kickflip = scoring .data .by_id(96) @@ -55,9 +73,23 @@ fn main() -> Result<(), String> { scoring.advance(frame(0, FilteredCategory::Ground, None))?; for tick in 1..61 { scoring.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + publish_hud(&scoring, &mut hud)?; } for tick in 61..70 { scoring.advance(frame(tick, FilteredCategory::Ground, None))?; + publish_hud(&scoring, &mut hud)?; + if scoring.close_tricks { + return Err("Landing emitted the native bail/close event".into()); + } + } + if scoring.hud_input().sequence_score != 100 || scoring.hud_input().line_time <= 0. { + return Err("Banked landing lost its displayed score/line timer".into()); + } + if scoring.hud_input().line_time != scoring.session.line.points / scoring.data.line_drain { + return Err("HUD timer is not in native seconds".into()); + } + if !hud.vm.get(hud.bindings.movie.root, "_visible").truth() { + return Err("Original APT root disappeared on landing".into()); } let banked = scoring.session.holder.snapshot.last_reward; if banked != 100. { @@ -67,12 +99,14 @@ fn main() -> Result<(), String> { } for tick in 70..80 { scoring.advance(frame(tick, FilteredCategory::Ground, None))?; + publish_hud(&scoring, &mut hud)?; } if scoring.session.holder.snapshot.last_reward != banked { return Err("Idle frame published the sequence again".into()); } for tick in 80..140 { scoring.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + publish_hud(&scoring, &mut hud)?; } let mut cancelled = frame(140, FilteredCategory::Ground, None); cancelled.teleported = true; @@ -85,8 +119,20 @@ fn main() -> Result<(), String> { if scoring.session.combo.multiplier != 1. { return Err("Teleport retained multiplier".into()); } + // An independent supplied-data sequence checks natural line expiry. + let mut expired = scoring_runtime::Runtime::load(&data)?; + expired.advance(frame(0, FilteredCategory::Ground, None))?; + for tick in 1..61 { + expired.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + } + for tick in 61..300 { + expired.advance(frame(tick, FilteredCategory::Ground, None))?; + } + if expired.hud_input().sequence_score != 0 || expired.hud_input().line_time != 0. { + return Err("Expired line retained its HUD score".into()); + } println!( - "Scoring data audit: authored kickflip credited once; idle publication stable; teleport cancels pending rewards and multiplier" + "Scoring data audit: authored kickflip credited once; landing display persists; timer uses seconds; line expiry clears score; teleport cancels pending rewards and multiplier" ); Ok(()) } diff --git a/crates/skate-game/src/apt_scene.rs b/crates/skate-game/src/apt_scene.rs index dcfd5dc1..6c8e75a9 100644 --- a/crates/skate-game/src/apt_scene.rs +++ b/crates/skate-game/src/apt_scene.rs @@ -123,70 +123,89 @@ fn visit( } else if let Some(text) = &character.text { let font = &movie.text_assets.fonts [&(text["font_id"].as_i64().ok_or("Invalid text font")? as i32)]; - let height = text["font_height"].as_f64().ok_or("Invalid text height")? as f32; - let sx = font.scale[0] * height; - let sy = font.scale[1] * height; - let value = vm.get(id, "_displayText").text(); - let bounds = character.bounds.ok_or("Missing text bounds")?; - let width = font.width(&value, height); - let alignment = text["alignment"].as_u64().unwrap_or(0); - let autosize = vm.get(id, "autoSize").text(); - let alignment = if autosize == "left" { 0 } else { alignment }; - let mut x = bounds[0] - + font.offset[0] * sx - + match alignment { - 1 => bounds[2] - bounds[0] - width, - 2 => (bounds[2] - bounds[0] - width) * 0.5, - _ => 0., - }; - let y = bounds[1] + font.offset[1] * sy; - let mut vertices = Vec::new(); - for c in value.chars() { - if let Some(g) = font.glyph(c) { - let x0 = x + g.x_offset * sx; - let y0 = y + (font.ascent - g.y_offset) * sy; - let x1 = x0 + g.width * sx; - let y1 = y0 + g.height * sy; - let [u0, v0, u1, v1] = g.atlas_bounds; - let points = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; - let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; - for i in [0, 1, 2, 0, 2, 3] { - vertices.push(transform( - matrix, - &Vertex { - position: points[i], - uv: [ - uvs[i][0] / font.size[0] as f32, - uvs[i][1] / font.size[1] as f32, - ], - }, - )); + // Native shadow text uses a black atlas pass followed by the sharp + // futuraheavy glyphs translated +1 in text X (825D6B68/82CA1FD8). + let passes = if let Some(foreground) = font.foreground { + vec![ + (font, true, 0.), + (&movie.text_assets.fonts[&foreground], false, 1.), + ] + } else { + vec![(font, false, 0.)] + }; + for (font, shadow, advance_x) in passes { + let height = text["font_height"].as_f64().ok_or("Invalid text height")? as f32; + let sx = font.scale[0] * height; + let sy = font.scale[1] * height; + let value = vm.get(id, "_displayText").text(); + let bounds = character.bounds.ok_or("Missing text bounds")?; + let width = font.width(&value, height); + let alignment = text["alignment"].as_u64().unwrap_or(0); + let autosize = vm.get(id, "autoSize").text(); + let alignment = if autosize == "left" { 0 } else { alignment }; + let mut x = bounds[0] + + advance_x + + font.offset[0] * sx + + match alignment { + 1 => bounds[2] - bounds[0] - width, + 2 => (bounds[2] - bounds[0] - width) * 0.5, + _ => 0., + }; + let y = bounds[1] + font.offset[1] * sy; + let mut vertices = Vec::new(); + for c in value.chars() { + if let Some(g) = font.glyph(c) { + let x0 = x + g.x_offset * sx; + let y0 = y + (font.ascent - g.y_offset) * sy; + let x1 = x0 + g.width * sx; + let y1 = y0 + g.height * sy; + let [u0, v0, u1, v1] = g.atlas_bounds; + let points = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; + for i in [0, 1, 2, 0, 2, 3] { + vertices.push(transform( + matrix, + &Vertex { + position: points[i], + uv: [ + uvs[i][0] / font.size[0] as f32, + uvs[i][1] / font.size[1] as f32, + ], + }, + )); + } + x = g.x_advance.mul_add(sx, x); } - x = g.x_advance.mul_add(sx, x); } - } - let argb = u32::from_str_radix( - text["color_argb"] - .as_str() - .ok_or("Missing text color")? - .trim_start_matches('#'), - 16, - ) - .map_err(|e| e.to_string())?; - let color = [ - ((argb >> 16) & 255) as f32 / 255., - ((argb >> 8) & 255) as f32 / 255., - (argb & 255) as f32 / 255., - (argb >> 24) as f32 / 255., - ]; - if !vertices.is_empty() { - out.push(Draw { - texture: font.texture.clone(), - size: font.size, - vertices, - multiply: std::array::from_fn(|i| multiply[i] * color[i]), - add, - }); + let argb = u32::from_str_radix( + text["color_argb"] + .as_str() + .ok_or("Missing text color")? + .trim_start_matches('#'), + 16, + ) + .map_err(|e| e.to_string())?; + let color = [ + ((argb >> 16) & 255) as f32 / 255., + ((argb >> 8) & 255) as f32 / 255., + (argb & 255) as f32 / 255., + (argb >> 24) as f32 / 255., + ]; + if !vertices.is_empty() { + out.push(Draw { + texture: font.texture.clone(), + size: font.size, + vertices, + multiply: std::array::from_fn(|i| { + if shadow && i < 3 { + 0. + } else { + multiply[i] * color[i] + } + }), + add, + }); + } } } for child in instance.children.values() { diff --git a/crates/skate-game/src/apt_text.rs b/crates/skate-game/src/apt_text.rs index 752ac5eb..47349d30 100644 --- a/crates/skate-game/src/apt_text.rs +++ b/crates/skate-game/src/apt_text.rs @@ -14,6 +14,8 @@ pub struct Glyph { } #[derive(Clone)] pub struct Font { + /// 825D6B68 attaches futuraheavy to Futura Shadow; 82CA1FD8 draws both. + pub foreground: Option, pub texture: String, pub size: [u32; 2], pub scale: [f32; 2], @@ -51,6 +53,7 @@ impl TextAssets { .ok_or_else(|| format!("Missing native font {name}.{key}")) }; let mut font = Font { + foreground: None, texture: asset["texture"] .as_str() .ok_or("Font texture missing")? @@ -93,6 +96,17 @@ impl TextAssets { font, ); } + for c in json["characters"].as_array().unwrap() { + if c["type_name"] == "font" && c["font"]["name"] == "Futura Shadow" { + let foreground = json["characters"].as_array().unwrap().iter().find(|f| + f["type_name"] == "font" && json["font_mappings"][f["font"]["name"].as_str().unwrap_or("")]["file_name"] == "futuraheavy") + .and_then(|f| f["id"].as_i64()).ok_or("Missing native Futura Shadow foreground font")? as i32; + out.fonts + .get_mut(&(c["id"].as_i64().unwrap() as i32)) + .unwrap() + .foreground = Some(foreground); + } + } Ok(out) } pub fn localize(&self, text: &str) -> String { diff --git a/crates/skate-game/src/hud_composite.wgsl b/crates/skate-game/src/hud_composite.wgsl new file mode 100644 index 00000000..b122dfb2 --- /dev/null +++ b/crates/skate-game/src/hud_composite.wgsl @@ -0,0 +1,7 @@ +#import bevy_ui::ui_vertex_output::UiVertexOutput +@group(1) @binding(0) var hud: texture_2d; +@group(1) @binding(1) var hud_sampler: sampler; +@fragment +fn fragment(in: UiVertexOutput) -> @location(0) vec4 { + return textureSample(hud, hud_sampler, in.uv); +} diff --git a/crates/skate-game/src/hud_runtime.rs b/crates/skate-game/src/hud_runtime.rs index 7a6b50a1..afa55279 100644 --- a/crates/skate-game/src/hud_runtime.rs +++ b/crates/skate-game/src/hud_runtime.rs @@ -210,7 +210,8 @@ impl Runtime { if previous.multiplier != self.bindings.input.multiplier { methods.push("UpdateMultiplier"); } - if previous.sequence_score != self.bindings.input.sequence_score + if previous.sequence_timer != self.bindings.input.sequence_timer + || previous.sequence_score != self.bindings.input.sequence_score || previous.clean != self.bindings.input.clean || previous.sketchy != self.bindings.input.sketchy || previous.line_score != self.bindings.input.line_score diff --git a/crates/skate-game/src/scoring_hud.rs b/crates/skate-game/src/scoring_hud.rs index 53ef100f..47a5074e 100644 --- a/crates/skate-game/src/scoring_hud.rs +++ b/crates/skate-game/src/scoring_hud.rs @@ -5,10 +5,12 @@ use bevy::{ camera::{RenderTarget, visibility::RenderLayers}, prelude::*, render::render_resource::{ - AsBindGroup, Extent3d, PrimitiveTopology, ShaderType, TextureDimension, TextureFormat, + AsBindGroup, BlendState, Extent3d, PrimitiveTopology, RenderPipelineDescriptor, ShaderType, + TextureDimension, TextureFormat, }, shader::ShaderRef, sprite_render::{AlphaMode2d, Material2d, Material2dPlugin, MeshMaterial2d}, + ui_render::UiMaterialPlugin, }; use std::{collections::BTreeMap, path::PathBuf}; @@ -33,6 +35,26 @@ impl Material2d for HudMaterial { AlphaMode2d::Blend } } +/// The offscreen target already contains RGB multiplied by coverage. Applying +/// ImageNode's straight-alpha blend again suppresses the original soft glow. +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +struct HudComposite { + #[texture(0)] + #[sampler(1)] + image: Handle, +} +impl UiMaterial for HudComposite { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/hud_composite.wgsl".into() + } + fn specialize(descriptor: &mut RenderPipelineDescriptor, _: UiMaterialKey) { + if let Some(fragment) = &mut descriptor.fragment { + for target in fragment.targets.iter_mut().flatten() { + target.blend = Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING); + } + } + } +} struct Slot { entity: Entity, mesh: Handle, @@ -52,21 +74,25 @@ pub(crate) struct ScoringHudPlugin; impl Plugin for ScoringHudPlugin { fn build(&self, app: &mut App) { embedded_asset!(app, "hud_render.wgsl"); - app.add_plugins(Material2dPlugin::::default()) - .add_systems( - PostStartup, - setup.after(crate::graphics_menu::PresentationSetup), - ) - .add_systems( - FixedUpdate, - advance - .after(crate::app::SimulationSet::Physics) - .run_if(crate::graphics_menu::gameplay_active), - ) - .add_systems( - Update, - (reset, render).chain().after(crate::app::FrameSet::Physics), - ); + embedded_asset!(app, "hud_composite.wgsl"); + app.add_plugins(( + Material2dPlugin::::default(), + UiMaterialPlugin::::default(), + )) + .add_systems( + PostStartup, + setup.after(crate::graphics_menu::PresentationSetup), + ) + .add_systems( + FixedUpdate, + advance + .after(crate::app::SimulationSet::Physics) + .run_if(crate::graphics_menu::gameplay_active), + ) + .add_systems( + Update, + (reset, render).chain().after(crate::app::FrameSet::Physics), + ); } } fn setup( @@ -75,6 +101,7 @@ fn setup( skater: Res, cameras: Query>, mut images: ResMut>, + mut composites: ResMut>, ) { // The startup dependency also applies the presentation system's deferred // camera spawn before this query. Without it, setup silently lost the HUD. @@ -153,7 +180,7 @@ fn setup( Msaa::Off, )); commands.spawn(( - ImageNode::new(target), + MaterialNode(composites.add(HudComposite { image: target })), UiTargetCamera(output), GlobalZIndex(1), Pickable::IGNORE, diff --git a/crates/skate-game/src/scoring_runtime.rs b/crates/skate-game/src/scoring_runtime.rs index ed102ca1..e2569faf 100644 --- a/crates/skate-game/src/scoring_runtime.rs +++ b/crates/skate-game/src/scoring_runtime.rs @@ -113,8 +113,8 @@ impl Runtime { hud_runtime::Input { sequence_score: self.sequence_score as i32, line_score: self.session.holder.snapshot.line as i32, - sequence_timer: self.session.line.points as i32, - line_time: self.session.line.points, + sequence_timer: (self.session.line.points / self.data.line_drain) as i32, + line_time: self.session.line.points / self.data.line_drain, line_capacity: self.data.line_capacity, multiplier: self.session.combo.multiplier, clean: self.clean, @@ -561,7 +561,9 @@ impl Runtime { self.session .publish_sequence(&self.data.session_rules(), 1., bailout, true); self.sequence_active = false; - self.close_tricks = true; + // 82775328 -> 82774E88 closes only for ScoreModule reset/bail + // output 14630 (82DA4010/82DA4238), not a banked landing. + self.close_tricks = bailout; } else if self.sequence_active { let s = &self.session.holder.snapshot; self.sequence_score = (s.accumulated @@ -590,6 +592,9 @@ impl Runtime { + self.air_metrics.iter().sum::()) * self.session.combo.multiplier; } + if self.session.line.expired || f.teleported || bailout { + self.sequence_score = 0.; + } self.session.settle_line(f.teleported || bailout, active); self.previous = f.position; Ok(()) diff --git a/docs/native-scoring-hud.md b/docs/native-scoring-hud.md index 724a7aa3..591508d5 100644 --- a/docs/native-scoring-hud.md +++ b/docs/native-scoring-hud.md @@ -87,7 +87,7 @@ Validation on 2026-09-08: - The data-only scoring-flow example credits the authored 100-point stationary kickflip once and verifies idle stability and teleport cancellation. - The original HUD data-only audit passes 1,800 frames, with 172 VM slots, - 116 display instances, at most 31 draw batches and finite geometry. + 116 display instances, at most 33 draw batches and finite geometry. - The release target builds successfully with static MSVC CRT, without default features. Compiler warnings remain in the existing game and audit-only code. - No game, recomp, controller harness, gameplay automation, `--check-assets` @@ -98,7 +98,7 @@ The copied build and launcher are in ignored `logs/scoring/build`. The launcher uses this worktree's HUD cache and the existing owned asset installation and starts paused. It checks required paths before running and retains failures on screen. The executable SHA256 is -`deb1045925e1dd0e7143825d0320172eb2c26cf77e1c0f336d3475887557435d`. +`9b3458045e7078dcd6daca3e5bea89d16298315b751b4cac4f82f8a6531cd7c0`. The missing-HUD startup defect is fixed: HUD setup now depends on presentation setup, so Bevy applies the deferred camera spawn before the HUD queries it. @@ -107,6 +107,44 @@ caused HUD initialization to return permanently. The rebuilt launcher also saves runtime output to `logs/scoring/build/scoring-test.log`. This fix is release-compiled; interactive rendering has not been launched for verification. +## Landing lifetime and native font passes + +The landing hide bug came from sending `CloseTrickDisplay` at every score +publication. Native `82775328` calls `82774E88` only on scoring output byte +14630; `82DA4238` copies that from module byte128, selected by `82DA4010` +for its reset/bail category. `82774E88` sets backend byte184, which +`825E4F40` turns into event3, resolved by `825D3B38` to +`_global.TrickDisplay.CloseTrickDisplay`. Normal banking no longer emits it. +Line expiry clears the score and lets the authored Clear/outro actions run. + +The timer unit is now traced end-to-end: `82DA4238` writes line points divided +by drain rate to output+40; `82775328` copies it to backend+32; +`825C2910` and `825C2D90` truncate that value to seconds for ActionScript. +The previous raw-point binding was wrong. Changes in that seconds field now +also refresh `UpdateTrickScoring`. + +Observed font code: `825D6B68` compares the APT name with `Futura Shadow` +(string at `8220BD44`) and attaches `futuraheavy` (`8220BD54`) as a secondary +font, setting X adjustment 1 and the other adjustment 0. `82CA1FD8` first +sets the primary RGB multiplier to black while retaining alpha, draws it, +then restores the color and draws the foreground with the X adjustment. +The scene renderer now follows those two passes. FuturaOuterGlow remains +its separately authored tinted atlas pass. No outline radius, new glow +color or replacement glyphs were invented. + +The offscreen adapter also needed premultiplied-alpha compositing. The +mesh target already stores covered RGB; Bevy's ordinary ImageNode uses +straight-alpha blending, which multiplied coverage again and weakened +soft atlas edges. The dedicated HUD composite now uses premultiplied +blending to preserve the original layers' coverage. + +Validation: supplied scoring data exercises landing, retained HUD visibility, +seconds conversion, natural line expiry and teleport cancellation. The +1,800-frame original movie audit checks a black shadow/foreground pair and +finite geometry (33 maximum batches). Release compilation passes. User +screenshots guided the investigation but did not supply rendering constants; +no game, reference executable or GPU capture was launched for validation. + ## Native parity gaps These are implementation gaps, not merely missing gameplay validation: