Keep HUD after landing and restore native shadow font passes

This commit is contained in:
chazmm committed 2026-09-08 18:04:47 +01:00
1 parent 763da9bbf5
commit 142fe97a6d
9 files changed
+257 -88

No files matched your search

+14 -2
View File
@@ -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(),
@@ -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(())
}
+81 -62
View File
@@ -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() {
+14
View File
@@ -14,6 +14,8 @@ pub struct Glyph {
}
#[derive(Clone)]
pub struct Font {
/// 825D6B68 attaches futuraheavy to Futura Shadow; 82CA1FD8 draws both.
pub foreground: Option<i32>,
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 {
+7
View File
@@ -0,0 +1,7 @@
#import bevy_ui::ui_vertex_output::UiVertexOutput
@group(1) @binding(0) var hud: texture_2d<f32>;
@group(1) @binding(1) var hud_sampler: sampler;
@fragment
fn fragment(in: UiVertexOutput) -> @location(0) vec4<f32> {
return textureSample(hud, hud_sampler, in.uv);
}
+2 -1
View File
@@ -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
+44 -17
View File
@@ -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<Image>,
}
impl UiMaterial for HudComposite {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/hud_composite.wgsl".into()
}
fn specialize(descriptor: &mut RenderPipelineDescriptor, _: UiMaterialKey<Self>) {
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<Mesh>,
@@ -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::<HudMaterial>::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::<HudMaterial>::default(),
UiMaterialPlugin::<HudComposite>::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<SkaterRuntime>,
cameras: Query<Entity, With<IsDefaultUiCamera>>,
mut images: ResMut<Assets<Image>>,
mut composites: ResMut<Assets<HudComposite>>,
) {
// 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,
+8 -3
View File
@@ -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::<f32>())
* 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(())
+40 -2
View File
@@ -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: