Integrate scoring collectors and original APT HUD for interactive testing

This commit is contained in:
chazmm committed 2026-09-08 17:44:19 +01:00
1 parent cfeac6abe3
commit 2dbe4aaceb
24 files changed
+2678 -42

No files matched your search

+22 -7
View File
@@ -8,6 +8,7 @@ pub const SCORABLE_COUNT: usize = 332;
pub const SCORE_TYPE_COUNT: usize = 14;
pub mod carrier;
pub mod catalog;
pub mod conversions;
pub mod session;
pub mod timer;
@@ -64,6 +65,26 @@ impl Default for ScoreHolder {
}
impl ScoreHolder {
pub fn has_pending_sequence(&self) -> bool {
self.pending_sequence
}
/// Non-air collectors use6198 then add directly to accumulator32.
pub fn credit_trick(&mut self, scorable: Scorable, reward: f32) {
if !scorable.valid() || self.suppressed {
return;
}
self.count_trick(scorable);
self.snapshot.accumulated += reward;
}
fn count_trick(&mut self, scorable: Scorable) {
self.repetitions[scorable.id] = self.repetitions[scorable.id].saturating_add(1);
if scorable.score_type != 0 {
self.sequence_history[scorable.id] =
self.sequence_history[scorable.id].saturating_add(1);
self.type_history[scorable.score_type] =
self.type_history[scorable.score_type].saturating_add(1);
}
}
pub fn set_suppressed(&mut self, suppressed: bool) {
self.suppressed = suppressed;
}
@@ -75,13 +96,7 @@ impl ScoreHolder {
if !scorable.valid() || self.suppressed {
return;
}
self.repetitions[scorable.id] = self.repetitions[scorable.id].saturating_add(1);
if scorable.score_type != 0 {
self.sequence_history[scorable.id] =
self.sequence_history[scorable.id].saturating_add(1);
self.type_history[scorable.score_type] =
self.type_history[scorable.score_type].saturating_add(1);
}
self.count_trick(scorable);
let s = &mut self.snapshot;
if scorable.class != 5 {
s.general_pending += s.fingerflip_pending;
@@ -0,0 +1,335 @@
//! Scorable descriptor conversion links at820862A8, stride24.
pub const LINKS: [(i32, i32); 332] = [
(-1, -1), //0
(-1, -1), //1
(-1, -1), //2
(-1, -1), //3
(-1, -1), //4
(-1, -1), //5
(-1, -1), //6
(-1, -1), //7
(-1, -1), //8
(-1, -1), //9
(-1, -1), //10
(-1, -1), //11
(-1, -1), //12
(-1, -1), //13
(-1, -1), //14
(-1, -1), //15
(-1, -1), //16
(-1, -1), //17
(-1, -1), //18
(-1, -1), //19
(-1, -1), //20
(-1, -1), //21
(-1, -1), //22
(-1, -1), //23
(-1, -1), //24
(-1, -1), //25
(-1, -1), //26
(-1, -1), //27
(-1, -1), //28
(-1, -1), //29
(-1, -1), //30
(-1, -1), //31
(-1, -1), //32
(-1, -1), //33
(-1, -1), //34
(-1, -1), //35
(-1, -1), //36
(-1, -1), //37
(-1, -1), //38
(-1, -1), //39
(-1, -1), //40
(-1, -1), //41
(-1, -1), //42
(-1, -1), //43
(-1, -1), //44
(-1, -1), //45
(-1, -1), //46
(-1, -1), //47
(-1, -1), //48
(-1, -1), //49
(-1, -1), //50
(-1, -1), //51
(-1, -1), //52
(-1, -1), //53
(-1, -1), //54
(-1, -1), //55
(-1, -1), //56
(-1, -1), //57
(-1, -1), //58
(-1, -1), //59
(-1, -1), //60
(-1, -1), //61
(-1, -1), //62
(-1, -1), //63
(-1, -1), //64
(-1, -1), //65
(-1, -1), //66
(-1, -1), //67
(-1, -1), //68
(-1, -1), //69
(-1, -1), //70
(-1, -1), //71
(-1, -1), //72
(-1, -1), //73
(-1, -1), //74
(-1, -1), //75
(-1, -1), //76
(-1, -1), //77
(-1, -1), //78
(-1, -1), //79
(-1, -1), //80
(-1, -1), //81
(-1, -1), //82
(-1, -1), //83
(-1, -1), //84
(-1, -1), //85
(-1, -1), //86
(-1, -1), //87
(-1, -1), //88
(-1, -1), //89
(-1, -1), //90
(-1, -1), //91
(-1, -1), //92
(92, 92), //93
(93, 92), //94
(-1, -1), //95
(-1, -1), //96
(96, 96), //97
(97, 96), //98
(-1, -1), //99
(-1, 128), //100
(-1, 128), //101
(-1, 128), //102
(-1, 128), //103
(-1, 128), //104
(-1, 128), //105
(-1, -1), //106
(-1, -1), //107
(-1, -1), //108
(-1, -1), //109
(-1, -1), //110
(-1, -1), //111
(-1, -1), //112
(-1, -1), //113
(113, 113), //114
(114, 113), //115
(-1, -1), //116
(-1, -1), //117
(117, 117), //118
(118, 117), //119
(-1, -1), //120
(-1, -1), //121
(-1, -1), //122
(-1, -1), //123
(-1, -1), //124
(-1, -1), //125
(-1, -1), //126
(-1, -1), //127
(-1, -1), //128
(-1, -1), //129
(-1, -1), //130
(-1, -1), //131
(-1, -1), //132
(-1, -1), //133
(94, 92), //134
(98, 96), //135
(115, 113), //136
(119, 117), //137
(-1, -1), //138
(-1, -1), //139
(-1, -1), //140
(-1, -1), //141
(-1, -1), //142
(-1, -1), //143
(-1, -1), //144
(-1, -1), //145
(-1, -1), //146
(-1, -1), //147
(-1, -1), //148
(-1, -1), //149
(-1, -1), //150
(-1, -1), //151
(-1, -1), //152
(-1, -1), //153
(-1, -1), //154
(-1, -1), //155
(-1, -1), //156
(-1, -1), //157
(-1, -1), //158
(-1, -1), //159
(-1, -1), //160
(-1, -1), //161
(-1, -1), //162
(-1, -1), //163
(-1, -1), //164
(-1, -1), //165
(-1, -1), //166
(-1, -1), //167
(-1, -1), //168
(-1, -1), //169
(-1, -1), //170
(-1, -1), //171
(-1, -1), //172
(-1, -1), //173
(-1, -1), //174
(-1, -1), //175
(-1, -1), //176
(-1, -1), //177
(-1, -1), //178
(-1, -1), //179
(-1, -1), //180
(-1, -1), //181
(-1, -1), //182
(-1, -1), //183
(-1, -1), //184
(-1, -1), //185
(-1, -1), //186
(-1, -1), //187
(-1, -1), //188
(-1, -1), //189
(-1, -1), //190
(-1, -1), //191
(-1, -1), //192
(-1, -1), //193
(-1, -1), //194
(-1, -1), //195
(-1, -1), //196
(-1, -1), //197
(-1, -1), //198
(-1, -1), //199
(-1, -1), //200
(-1, -1), //201
(-1, -1), //202
(-1, -1), //203
(-1, -1), //204
(-1, -1), //205
(-1, -1), //206
(-1, -1), //207
(-1, -1), //208
(-1, -1), //209
(-1, -1), //210
(-1, -1), //211
(-1, -1), //212
(-1, -1), //213
(-1, -1), //214
(-1, -1), //215
(-1, -1), //216
(-1, -1), //217
(-1, -1), //218
(-1, -1), //219
(-1, -1), //220
(-1, -1), //221
(-1, -1), //222
(-1, -1), //223
(-1, -1), //224
(-1, -1), //225
(-1, -1), //226
(-1, -1), //227
(-1, -1), //228
(-1, -1), //229
(-1, -1), //230
(-1, -1), //231
(-1, -1), //232
(-1, -1), //233
(-1, -1), //234
(-1, -1), //235
(-1, -1), //236
(-1, -1), //237
(-1, -1), //238
(-1, -1), //239
(-1, -1), //240
(-1, -1), //241
(-1, -1), //242
(-1, -1), //243
(-1, -1), //244
(-1, -1), //245
(-1, -1), //246
(-1, -1), //247
(-1, -1), //248
(-1, -1), //249
(-1, -1), //250
(-1, -1), //251
(-1, -1), //252
(-1, -1), //253
(-1, -1), //254
(-1, -1), //255
(-1, -1), //256
(-1, -1), //257
(-1, -1), //258
(-1, -1), //259
(-1, -1), //260
(-1, -1), //261
(-1, -1), //262
(-1, -1), //263
(-1, -1), //264
(-1, -1), //265
(-1, -1), //266
(-1, -1), //267
(-1, -1), //268
(-1, -1), //269
(-1, -1), //270
(-1, -1), //271
(-1, -1), //272
(-1, -1), //273
(-1, -1), //274
(-1, -1), //275
(-1, -1), //276
(-1, -1), //277
(-1, -1), //278
(-1, -1), //279
(-1, -1), //280
(-1, -1), //281
(-1, -1), //282
(-1, -1), //283
(-1, -1), //284
(-1, -1), //285
(-1, -1), //286
(-1, -1), //287
(-1, -1), //288
(-1, -1), //289
(-1, -1), //290
(-1, -1), //291
(-1, -1), //292
(-1, -1), //293
(-1, -1), //294
(-1, -1), //295
(-1, -1), //296
(-1, -1), //297
(-1, -1), //298
(-1, -1), //299
(-1, -1), //300
(-1, -1), //301
(-1, -1), //302
(-1, -1), //303
(-1, -1), //304
(-1, -1), //305
(-1, -1), //306
(-1, -1), //307
(-1, -1), //308
(-1, -1), //309
(-1, -1), //310
(-1, -1), //311
(-1, -1), //312
(-1, -1), //313
(-1, -1), //314
(-1, -1), //315
(-1, -1), //316
(-1, -1), //317
(-1, -1), //318
(-1, -1), //319
(-1, -1), //320
(-1, -1), //321
(-1, -1), //322
(-1, -1), //323
(-1, -1), //324
(-1, -1), //325
(-1, -1), //326
(-1, -1), //327
(-1, -1), //328
(-1, -1), //329
(-1, -1), //330
(-1, -1), //331
];
@@ -0,0 +1,102 @@
//! Executes owned movie bytecode against a data-only host. No game or renderer.
#[path = "../../skate-game/src/apt_display.rs"]
mod apt_display;
#[path = "../../skate-game/src/apt_movie.rs"]
mod apt_movie;
#[path = "../../skate-game/src/apt_text.rs"]
mod apt_text;
#[path = "../../skate-game/src/apt_vm.rs"]
mod apt_vm;
use apt_vm::*;
struct Audit {
movie: apt_movie::Movie,
}
impl Host for Audit {
fn call(
&mut self,
vm: &mut Vm,
object: usize,
method: &str,
args: Vec<Value>,
) -> Result<Value, String> {
if self.movie.method(vm, object, method, &args)? {
return Ok(Value::Undefined);
}
match method {
"ASSetPropFlags" => Ok(Value::Undefined),
"GetLineTimerMaxPoints" => Ok(Value::Number(400.0)),
"GetLineTimeRemaining" | "GetLineScore" => Ok(Value::Number(0.0)),
"GetLanguage" => Ok(Value::Text("english".into())),
_ => Err(format!(
"Unimplemented audit binding {object}.{method}({args:?})"
)),
}
}
}
fn drain(vm: &mut Vm, host: &mut Audit) -> Result<(), String> {
let mut count = 0;
while let Some((object, offset)) = host.movie.pending.pop_front() {
count += 1;
if count > 4096 {
return Err("Frame action limit".into());
}
if !host.movie.instances.contains_key(&object) {
continue;
}
let code = host
.movie
.actions
.get(&offset.to_string())
.ok_or("Missing movie action block")?
.clone();
vm.run_on(object, &code, host)?;
}
Ok(())
}
fn main() -> Result<(), String> {
let path = std::env::args_os()
.nth(1)
.ok_or("Expected owned trickdisplay.json")?;
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let mut host = Audit {
movie: apt_movie::Movie::load(&json)?,
};
let mut vm = Vm::new();
for name in ["MovieClip", "Tricks", "FELanguage"] {
let object = vm.object(ObjectKind::Native(name.into()));
let proto = vm.object(ObjectKind::Plain);
vm.set(object, "prototype", Value::Object(proto))?;
vm.set(vm.global, name, Value::Object(object))?;
}
vm.set(vm.global, "Screen_EdgeOffset", Value::Number(0.0))?;
for c in host.movie.characters.values().cloned().collect::<Vec<_>>() {
for frame in c.frames {
for control in frame.controls {
if control.type_name == "do_init_action" {
let code = host.movie.actions[&control.actions_offset.to_string()].clone();
vm.run(&code, &mut host)?;
}
}
}
}
vm.begin_update();
host.movie.initialize(&mut vm)?;
drain(&mut vm, &mut host)?;
let Value::Object(controller) = vm.get(host.movie.root, "screen") else {
return Err("Root action did not construct controller".into());
};
vm.call_method(controller, "ScreenShow", vec![Value::Bool(true)], &mut host)?;
drain(&mut vm, &mut host)?;
for _ in 0..90 {
vm.begin_update();
host.movie.advance(&mut vm)?;
drain(&mut vm, &mut host)?;
}
println!(
"Original movie initialized and advanced 90 authored frames; {} retained display instances",
host.movie.instances.len()
);
Ok(())
}
+79
View File
@@ -0,0 +1,79 @@
//! Original action-stream validation with supplied data, without game systems.
#[path = "../../skate-game/src/apt_display.rs"]
mod apt_display;
#[path = "../../skate-game/src/apt_movie.rs"]
mod apt_movie;
#[path = "../../skate-game/src/apt_scene.rs"]
mod apt_scene;
#[path = "../../skate-game/src/apt_text.rs"]
mod apt_text;
#[path = "../../skate-game/src/apt_vm.rs"]
mod apt_vm;
#[path = "../../skate-game/src/hud_runtime.rs"]
mod hud_runtime;
use apt_vm::Value;
fn main() -> Result<(), String> {
let path = std::env::args_os()
.nth(1)
.ok_or("Expected owned trickdisplay.json")?;
let json: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let mut input = hud_runtime::Input {
sequence_score: 0,
line_score: 0,
sequence_timer: 0,
line_time: 0.0,
line_capacity: 400.0,
multiplier: 1.0,
clean: false,
sketchy: false,
stance: [false; 4],
trick_name: String::new(),
trick_metrics: std::array::from_fn(|_| Value::Undefined),
context_tricks: Vec::new(),
};
let mut runtime = hud_runtime::Runtime::load(&json, input.clone())?;
let shapes: apt_scene::Shapes =
serde_json::from_value(json["shapes"].clone()).map_err(|e| e.to_string())?;
let mut max_draws = 0;
for tick in 0..1800 {
let new_trick = tick % 180 == 10;
if new_trick {
input.trick_name = "Kickflip".into();
input.trick_metrics = [
Value::Text(input.trick_name.clone()),
Value::Number(0.0),
Value::Number(0.0),
Value::Bool(false),
Value::Bool(false),
];
input.sequence_score += 100;
input.line_score += 100;
input.clean = tick % 360 == 10;
input.sketchy = !input.clean;
input.line_time = 400.0;
input.multiplier = if input.multiplier == 1.5 { 2.0 } else { 1.5 };
}
input.line_time = (input.line_time - 1.0).max(0.0);
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());
if draws
.iter()
.flat_map(|d| &d.vertices)
.any(|v| v.position.iter().chain(v.uv.iter()).any(|x| !x.is_finite()))
{
return Err("Nonfinite authored draw geometry".into());
}
}
println!(
"Original HUD action audit passed 1800 authored frames; {} object slots, {} display instances",
runtime.vm.objects.len(),
runtime.bindings.movie.instances.len()
);
println!("Validated glyph/shape traversal: maximum {max_draws} draw batches");
Ok(())
}
@@ -0,0 +1,92 @@
//! Original action-stream validation with supplied data, without game systems.
#[path = "../../skate-game/src/apt_display.rs"]
mod apt_display;
#[path = "../../skate-game/src/apt_movie.rs"]
mod apt_movie;
#[path = "../../skate-game/src/apt_scene.rs"]
mod apt_scene;
#[path = "../../skate-game/src/apt_text.rs"]
mod apt_text;
#[path = "../../skate-game/src/apt_vm.rs"]
mod apt_vm;
#[path = "../../skate-game/src/hud_runtime.rs"]
mod hud_runtime;
#[path = "../../skate-game/src/scoring_runtime.rs"]
mod scoring_runtime;
use skate_core::{
animation::output::attributes::AttributeName, physics::filtered_state::FilteredCategory,
};
fn frame(
tick: u32,
category: FilteredCategory,
descriptor: Option<AttributeName>,
) -> scoring_runtime::Frame {
scoring_runtime::Frame {
tick,
dt: 1. / 60.,
category,
state: 100,
descriptor,
grind_id: -1,
flags: 0,
position: [0., 0., 0.],
velocity: [0., 0., 5.],
forward: [0., 0., 1.],
switch: false,
fakie: false,
nollie: false,
body_flip: false,
landing: Default::default(),
teleported: false,
reverting: false,
}
}
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 kickflip = scoring
.data
.by_id(96)
.ok_or("Missing kickflip")?
.encoded_name;
scoring.advance(frame(0, FilteredCategory::Ground, None))?;
for tick in 1..61 {
scoring.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?;
}
for tick in 61..70 {
scoring.advance(frame(tick, FilteredCategory::Ground, None))?;
}
let banked = scoring.session.holder.snapshot.last_reward;
if banked != 100. {
return Err(format!(
"Stationary unswitched kickflip, no landing bonus: expected authored 100, got {banked}"
));
}
for tick in 70..80 {
scoring.advance(frame(tick, FilteredCategory::Ground, None))?;
}
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)))?;
}
let mut cancelled = frame(140, FilteredCategory::Ground, None);
cancelled.teleported = true;
scoring.advance(cancelled)?;
if scoring.session.holder.snapshot.last_reward != 0.
|| scoring.session.holder.has_pending_sequence()
{
return Err("Teleport retained pending trick rewards".into());
}
if scoring.session.combo.multiplier != 1. {
return Err("Teleport retained multiplier".into());
}
println!(
"Scoring data audit: authored kickflip credited once; idle publication stable; teleport cancels pending rewards and multiplier"
);
Ok(())
}
+1
View File
@@ -8,6 +8,7 @@ pub mod animation_frames;
pub mod animation_metadata;
pub mod collections;
pub mod scoring;
mod scoring_fields;
pub mod attrib_hash;
pub mod input_config;
pub mod gesture_patterns;
+42
View File
@@ -25,6 +25,7 @@ pub struct Definition {
#[derive(Clone, Debug)]
pub struct ScoringData {
pub collector: CollectorTuning,
pub definitions: Vec<Definition>,
pub repetition: PointGraph<8>,
pub announcement: PointGraph<8>,
@@ -37,6 +38,7 @@ pub struct ScoringData {
pub combo_refresh_threshold: f32,
pub unannounced_factor: f32,
pub bail_factor: f32,
pub sketchy_side_speed: f32,
}
impl ScoringData {
@@ -103,6 +105,7 @@ impl ScoringData {
};
let f = |field| data.float(TUNING, "default", field);
Ok(Self {
collector: CollectorTuning::load(data)?,
definitions,
repetition: graph("Hash_59D91EAABF033A24")?,
announcement: graph("Hash_263B277F8CA17126")?,
@@ -118,6 +121,7 @@ impl ScoringData {
combo_refresh_threshold: f("Hash_E36197BFC8EA1CAD")?,
unannounced_factor: f("Hash_2577DF0FCE5251E4")?,
bail_factor: f("Hash_B0B56FF046508506")?,
sketchy_side_speed: f("Hash_6DC5982591F80A4C")?,
})
}
pub fn by_name(&self, name: AttributeName) -> Option<&Definition> {
@@ -127,3 +131,41 @@ impl ScoringData {
self.definitions.iter().find(|d| d.metadata.id == id)
}
}
#[derive(Clone, Debug)]
pub struct CollectorTuning {
scalars: std::collections::BTreeMap<u16, f32>,
curves: std::collections::BTreeMap<u16, PointGraph<8>>,
}
impl CollectorTuning {
fn load(data: &Collections) -> Result<Self, String> {
let mut out = Self {
scalars: Default::default(),
curves: Default::default(),
};
for &(offset, field, size) in crate::scoring_fields::FIELDS {
if size == 4 {
out.scalars.insert(
offset,
data.float("Hash_546C36B656038E04", "default", field)?,
);
} else {
let w = data.words::<20>("Hash_546C36B656038E04", "default", field)?;
out.curves.insert(
offset,
PointGraph {
x: std::array::from_fn(|i| f32::from_bits(w[4 + i])),
y: std::array::from_fn(|i| f32::from_bits(w[12 + i])),
},
);
}
}
Ok(out)
}
pub fn scalar(&self, native_offset: u16) -> f32 {
self.scalars[&native_offset]
}
pub fn curve(&self, native_offset: u16, input: f32) -> f32 {
self.curves[&native_offset].evaluate(input)
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Score collector VLT class546C36B656038E04 schema layout.
pub const FIELDS: &[(u16, &str, usize)] = &[
(0x000, "Hash_8D544250D732B9A8", 80),
(0x050, "Hash_130B361B9E4D9369", 80),
(0x0a0, "Hash_156F53F56E861668", 80),
(0x0f0, "Hash_6DA2DFA9C8E3D863", 80),
(0x140, "Hash_9C9EFF9DB25BF70A", 80),
(0x190, "Hash_004A238881254FB5", 80),
(0x1e0, "Hash_198F48320EBB7C3A", 80),
(0x230, "Hash_F94C77A0FB410FE3", 80),
(0x280, "Hash_D7FBBD6E5E1EDF37", 80),
(0x2d0, "Hash_E78EDEC321055E09", 80),
(0x320, "Hash_8C24CF7B8227B348", 80),
(0x370, "Hash_5A756D4B3A5036DD", 80),
(0x3c0, "Hash_B552FB2712A897D6", 80),
(0x410, "Hash_205C814AA3ACA8D4", 80),
(0x460, "Hash_94F7B93068FE6FB1", 80),
(0x4b0, "Hash_7E17CC5298BBA36E", 80),
(0x500, "Hash_2D52116C69E3E1BE", 80),
(0x550, "Hash_1F647002596E4157", 80),
(0x5a0, "Hash_91529F3BD9F4DD25", 80),
(0x5f0, "Hash_66216D696EEAD314", 4),
(0x5f4, "Hash_3A444577C4D4661E", 4),
(0x5f8, "Hash_B26A66F7634E760E", 4),
(0x5fc, "Hash_BFDA1A507E2E55A1", 4),
(0x600, "Hash_1CA631042D292121", 4),
(0x604, "Hash_04A1775CB52A8E4D", 4),
(0x608, "Hash_AD14550EFF3920DE", 4),
(0x60c, "Hash_4BEA9C285A3CDA06", 4),
(0x610, "Hash_F813B960D0450300", 4),
(0x614, "Hash_391821526C05A7F1", 4),
(0x618, "Hash_B3C69AAF221F34F9", 4),
(0x61c, "Hash_E2AFE1B06292209D", 4),
(0x620, "Hash_62B9C3DAA846E240", 4),
(0x624, "Hash_B0D7A3876D2D274E", 4),
(0x628, "Hash_77DD0728B4736957", 4),
(0x62c, "Hash_05BBAF2E866E083E", 4),
(0x630, "Hash_5A2D1476F08690EC", 4),
(0x634, "Hash_001BF89D79FA1CCB", 4),
(0x638, "Hash_40E8618821693CD3", 4),
(0x63c, "Hash_25B11E405C140E61", 4),
(0x640, "Hash_694410FC8D57E794", 4),
(0x644, "Hash_2BE55EDC393F3D9B", 4),
(0x648, "Hash_F1F451420D10D2C5", 4),
(0x64c, "Hash_3BD67FAE1E671F26", 4),
(0x650, "Hash_F577378023AADAA2", 4),
(0x654, "Hash_16CB3F5D3D560676", 4),
(0x658, "Hash_67EBCA6DBB3EE9F1", 4),
(0x65c, "Hash_BA0B9725B1F7ABA7", 4),
(0x660, "Hash_C5BEC05471041171", 4),
(0x664, "Hash_BC91E9C84E099F2C", 4),
(0x668, "Hash_C33E4AA507A2CB4C", 4),
(0x66c, "Hash_AB8EACF72B291746", 4),
(0x670, "Hash_E5897F067B3C4F77", 4),
(0x674, "Hash_2B55A2C725912CBF", 4),
(0x678, "Hash_092D5E5FDE988D1B", 4),
(0x67c, "Hash_44746535286E1613", 4),
(0x680, "Hash_0BA6F149F1462EB0", 4),
(0x684, "Hash_EE42BA52A1313FDB", 4),
(0x688, "Hash_945369EBD1E9A062", 4),
(0x68c, "Hash_123E054E8895CAFE", 4),
(0x690, "Hash_63E61D37475E6AB9", 4),
(0x694, "Hash_AC54DE5C98B5DFAD", 4),
(0x698, "Hash_C4BDC0CD02C83A58", 4),
];
+2 -1
View File
@@ -105,6 +105,7 @@ pub(crate) fn build(
crate::render_capacity::RenderCapacityPlugin,
verification::VerificationPlugin,
crate::performance::PerformancePlugin,
));
))
.add_plugins(crate::scoring_hud::ScoringHudPlugin);
app
}
+1
View File
@@ -5,6 +5,7 @@ use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize)]
pub struct Control {
pub label: Option<String>,
pub type_name: String,
#[serde(default)]
pub flags: u32,
+320
View File
@@ -0,0 +1,320 @@
//! Original APT movie hierarchy and timeline control, independent of Bevy.
use crate::{
apt_display::{Control, DisplayList, Placement},
apt_vm::{Instruction, ObjectKind, Value, Vm},
};
use serde::Deserialize;
use std::collections::{BTreeMap, VecDeque};
#[derive(Clone, Deserialize)]
pub struct Frame {
pub controls: Vec<Control>,
}
#[derive(Clone, Deserialize)]
pub struct Character {
pub id: i32,
pub type_name: String,
#[serde(default)]
pub frames: Vec<Frame>,
pub text: Option<serde_json::Value>,
pub bounds: Option<[f32; 4]>,
}
#[derive(Clone)]
pub struct Instance {
pub character: i32,
pub frame: usize,
pub playing: bool,
pub children: BTreeMap<i32, usize>,
pub placement: Option<Placement>,
}
pub struct Movie {
pub characters: BTreeMap<i32, Character>,
pub instances: BTreeMap<usize, Instance>,
pub actions: BTreeMap<String, Vec<Instruction>>,
pub pending: VecDeque<(usize, u32)>,
pub root: usize,
pub text_assets: crate::apt_text::TextAssets,
states: BTreeMap<i32, Vec<DisplayList>>,
}
impl Movie {
pub fn load(json: &serde_json::Value) -> Result<Self, String> {
let characters: Vec<Character> =
serde_json::from_value(json["characters"].clone()).map_err(|e| e.to_string())?;
let mut states = BTreeMap::new();
for c in &characters {
let mut list = DisplayList::default();
let mut frames = Vec::new();
for frame in &c.frames {
for control in &frame.controls {
list.apply(control)?;
}
frames.push(list.clone());
}
states.insert(c.id, frames);
}
Ok(Self {
characters: characters.into_iter().map(|c| (c.id, c)).collect(),
instances: BTreeMap::new(),
actions: serde_json::from_value(json["actions"].clone()).map_err(|e| e.to_string())?,
pending: VecDeque::new(),
root: usize::MAX,
text_assets: crate::apt_text::TextAssets::load(json)?,
states,
})
}
pub fn initialize(&mut self, vm: &mut Vm) -> Result<(), String> {
self.root = self.create(vm, 0, None, 0)?;
vm.set(self.root, "_root", Value::Object(self.root))?;
// Every clip sees the same authored root, including unnamed children.
for id in self.instances.keys() {
vm.set(*id, "_root", Value::Object(self.root))?;
}
Ok(())
}
fn create(
&mut self,
vm: &mut Vm,
character: i32,
parent: Option<usize>,
depth: usize,
) -> Result<usize, String> {
if depth > 32 || self.instances.len() > 2048 {
return Err("APT movie hierarchy limit".into());
}
let c = self
.characters
.get(&character)
.ok_or("APT unknown character")?
.clone();
let id = vm.object(ObjectKind::Native(format!("movie:{character}")));
if self.root != usize::MAX {
vm.set(id, "_root", Value::Object(self.root))?;
}
if let Some(parent) = parent {
vm.set(id, "_parent", Value::Object(parent))?;
}
vm.set(id, "_x", Value::Number(0.0))?;
vm.set(id, "_y", Value::Number(0.0))?;
vm.set(id, "_visible", Value::Bool(true))?;
vm.set(id, "_alpha", Value::Number(100.0))?;
if let Some(text) = &c.text {
vm.set(
id,
"text",
Value::Text(text["initial_text"].as_str().unwrap_or("").into()),
)?;
}
self.instances.insert(
id,
Instance {
character,
frame: 0,
playing: !c.frames.is_empty(),
children: BTreeMap::new(),
placement: None,
},
);
self.text_changed(vm, id)?;
if !c.frames.is_empty() {
self.seek(vm, id, 0, depth + 1)?;
}
Ok(id)
}
pub fn text_changed(&self, vm: &mut Vm, id: usize) -> Result<(), String> {
let Some(instance) = self.instances.get(&id) else {
return Ok(());
};
let character = &self.characters[&instance.character];
let Some(text) = &character.text else {
return Ok(());
};
let value = self.text_assets.localize(&vm.get(id, "text").text());
vm.set(id, "_displayText", Value::Text(value.clone()))?;
let font = self
.text_assets
.fonts
.get(&(text["font_id"].as_i64().ok_or("Invalid text font id")? as i32))
.ok_or("Missing original text font")?;
let height = text["font_height"].as_f64().ok_or("Invalid text size")? as f32;
let width = font.width(&value, height);
vm.set(id, "textWidth", Value::Number(width as f64))?;
let bounds = character.bounds.ok_or("Missing text bounds")?;
let autosize = vm.get(id, "autoSize").text();
vm.set(
id,
"_width",
Value::Number(
if autosize == "left" || autosize == "right" || autosize == "center" {
width
} else {
bounds[2] - bounds[0]
} as f64,
),
)?;
Ok(())
}
fn remove(&mut self, vm: &mut Vm, id: usize) {
if let Some(instance) = self.instances.remove(&id) {
for child in instance.children.values() {
self.remove(vm, *child);
}
}
// Retired handles can remain referenced by ActionScript, but no longer
// participate in timeline advancement or rendering.
let _ = vm.set(id, "_visible", Value::Bool(false));
}
pub fn seek(
&mut self,
vm: &mut Vm,
id: usize,
frame: usize,
nesting: usize,
) -> Result<(), String> {
let instance = self
.instances
.get(&id)
.ok_or("APT absent movie instance")?
.clone();
let character = self
.characters
.get(&instance.character)
.ok_or("APT absent movie character")?;
if frame >= character.frames.len() {
return Err(format!(
"APT frame {frame} outside character {}",
character.id
));
}
let frame_count = character.frames.len();
let actions: Vec<_> = character.frames[frame]
.controls
.iter()
.filter(|c| c.type_name == "do_action" && c.actions_offset != 0)
.map(|c| c.actions_offset)
.collect();
let list = self.states[&instance.character][frame].clone();
let mut children = BTreeMap::new();
for (depth, placement) in list.depths {
let previous = instance.children.get(&depth).copied();
if let Some(name) = previous
.and_then(|old| self.instances.get(&old))
.and_then(|i| i.placement.as_ref())
.map(|p| p.name.clone())
{
if !name.is_empty() && name != placement.name {
vm.objects[id].fields.remove(&name);
}
}
let child = if let Some(old) = previous.filter(|old| {
self.instances
.get(old)
.is_some_and(|i| i.character == placement.character)
}) {
old
} else {
if let Some(old) = previous {
self.remove(vm, old);
}
self.create(vm, placement.character, Some(id), nesting + 1)?
};
let old = self.instances[&child].placement.as_ref();
if old.is_none_or(|old| old.matrix != placement.matrix) {
vm.set(child, "_x", Value::Number(placement.matrix[4] as f64))?;
vm.set(child, "_y", Value::Number(placement.matrix[5] as f64))?;
}
if !placement.name.is_empty() {
vm.set(id, &placement.name, Value::Object(child))?;
}
self.instances.get_mut(&child).unwrap().placement = Some(placement);
children.insert(depth, child);
}
for (depth, child) in &instance.children {
if !children.contains_key(depth) {
if let Some(name) = self
.instances
.get(child)
.and_then(|i| i.placement.as_ref())
.map(|p| p.name.clone())
{
if !name.is_empty() {
vm.objects[id].fields.remove(&name);
}
}
self.remove(vm, *child);
}
}
let current = self.instances.get_mut(&id).unwrap();
current.frame = frame;
current.children = children;
vm.set(id, "_currentframe", Value::Number((frame + 1) as f64))?;
vm.set(id, "_totalframes", Value::Number(frame_count as f64))?;
for offset in actions {
self.pending.push_back((id, offset));
}
Ok(())
}
pub fn advance(&mut self, vm: &mut Vm) -> Result<(), String> {
let playing: Vec<_> = self
.instances
.iter()
.filter(|(_, i)| i.playing)
.map(|(id, i)| (*id, i.character, i.frame))
.collect();
for (id, character, frame) in playing {
if !self.instances.contains_key(&id) {
continue;
}
let count = self.characters[&character].frames.len();
if count > 1 {
self.seek(vm, id, (frame + 1) % count, 0)?;
}
}
Ok(())
}
pub fn method(
&mut self,
vm: &mut Vm,
id: usize,
method: &str,
args: &[Value],
) -> Result<bool, String> {
if !self.instances.contains_key(&id) {
return Ok(false);
}
match method {
"stop" => self.instances.get_mut(&id).unwrap().playing = false,
"play" => self.instances.get_mut(&id).unwrap().playing = true,
"gotoAndPlay" | "gotoAndStop" => {
let c = &self.characters[&self.instances[&id].character];
let frame = match args.first().ok_or("APT goto requires frame")? {
Value::Text(label) => c
.frames
.iter()
.position(|f| {
f.controls.iter().any(|control| {
control.type_name == "frame_label"
&& control.label.as_deref() == Some(label)
})
})
.ok_or_else(|| format!("APT character {} lacks label {label}", c.id))?,
value => {
let frame = value.number();
if !frame.is_finite() || frame < 0.0 {
return Err(format!(
"Invalid APT frame {frame} in {} on character {}",
method, c.id
));
}
// The shipped line timer deliberately requests zero at
// full capacity. Frame zero addresses the first frame.
(frame as usize).saturating_sub(1)
}
};
self.seek(vm, id, frame, 0)?;
self.instances.get_mut(&id).unwrap().playing = method == "gotoAndPlay";
}
_ => return Ok(false),
}
Ok(true)
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Retained movie traversal. All geometry and glyphs come from the owned APT.
use crate::{apt_movie::Movie, apt_vm::Vm};
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Clone, Deserialize)]
pub struct Vertex {
pub position: [f32; 2],
pub uv: [f32; 2],
}
#[derive(Clone, Deserialize)]
pub struct Texture {
pub width: u32,
pub height: u32,
pub rgba: String,
}
#[derive(Clone, Deserialize)]
pub struct Shape {
pub texture: Texture,
pub triangles: Vec<[Vertex; 3]>,
pub color: [f32; 4],
}
pub type Shapes = BTreeMap<i32, Vec<Shape>>;
pub struct Draw {
pub texture: String,
pub size: [u32; 2],
pub vertices: Vec<Vertex>,
pub multiply: [f32; 4],
pub add: [f32; 4],
}
fn compose(a: [f32; 6], b: [f32; 6]) -> [f32; 6] {
[
a[0] * b[0] + a[2] * b[1],
a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3],
a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4],
a[1] * b[4] + a[3] * b[5] + a[5],
]
}
fn transform(m: [f32; 6], v: &Vertex) -> Vertex {
Vertex {
position: [
m[0] * v.position[0] + m[2] * v.position[1] + m[4],
m[1] * v.position[0] + m[3] * v.position[1] + m[5],
],
uv: v.uv,
}
}
pub fn draw(movie: &Movie, vm: &Vm, shapes: &Shapes) -> Result<Vec<Draw>, String> {
let mut draws = Vec::new();
visit(
movie,
vm,
shapes,
movie.root,
[1., 0., 0., 1., 0., 0.],
[1.; 4],
[0.; 4],
&mut draws,
)?;
Ok(draws)
}
fn visit(
movie: &Movie,
vm: &Vm,
shapes: &Shapes,
id: usize,
parent: [f32; 6],
pm: [f32; 4],
pa: [f32; 4],
out: &mut Vec<Draw>,
) -> Result<(), String> {
if !vm.get(id, "_visible").truth() {
return Ok(());
}
let instance = &movie.instances[&id];
let mut matrix = instance
.placement
.as_ref()
.map_or([1., 0., 0., 1., 0., 0.], |p| p.matrix);
matrix[4] = vm.get(id, "_x").number() as f32;
matrix[5] = vm.get(id, "_y").number() as f32;
let matrix = compose(parent, matrix);
if matrix.iter().any(|x| !x.is_finite()) {
return Err(format!(
"Nonfinite HUD transform on character {}",
instance.character
));
}
let color = instance
.placement
.as_ref()
.map_or([255, 255, 255, 255, 0, 0, 0, 0], |p| p.color);
let rgba = [1, 2, 3, 0];
let mut multiply = std::array::from_fn(|i| pm[i] * color[rgba[i]] as f32 / 255.);
let mut add = std::array::from_fn(|i| pa[i] + pm[i] * color[4 + rgba[i]] as f32 / 255.);
let alpha = (vm.get(id, "_alpha").number() as f32 / 100.).clamp(0., 1.);
multiply[3] *= alpha;
add[3] *= alpha;
if multiply[3] <= 0. && add[3] <= 0. {
return Ok(());
}
let character = &movie.characters[&instance.character];
if character.type_name == "shape" {
for shape in shapes
.get(&character.id)
.ok_or("Missing original shape geometry")?
{
out.push(Draw {
texture: shape.texture.rgba.clone(),
size: [shape.texture.width, shape.texture.height],
vertices: shape
.triangles
.iter()
.flatten()
.map(|v| transform(matrix, v))
.collect(),
multiply: std::array::from_fn(|i| multiply[i] * shape.color[i]),
add,
});
}
} 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,
],
},
));
}
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,
});
}
}
for child in instance.children.values() {
visit(movie, vm, shapes, *child, matrix, multiply, add, out)?;
}
Ok(())
}
+119
View File
@@ -0,0 +1,119 @@
//! SkateAptString82CA14D0 and cFont828076D0/82808708 layout.
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Clone, Deserialize)]
pub struct Glyph {
pub glyph_index: usize,
pub width: f32,
pub height: f32,
pub x_offset: f32,
pub y_offset: f32,
pub x_advance: f32,
pub atlas_bounds: [f32; 4],
}
#[derive(Clone)]
pub struct Font {
pub texture: String,
pub size: [u32; 2],
pub scale: [f32; 2],
pub offset: [f32; 2],
pub ascent: f32,
pub glyphs: BTreeMap<u32, Glyph>,
}
#[derive(Clone, Default)]
pub struct TextAssets {
pub fonts: BTreeMap<i32, Font>,
pub language: BTreeMap<String, String>,
}
impl TextAssets {
pub fn load(json: &serde_json::Value) -> Result<Self, String> {
let mut out = Self::default();
out.language =
serde_json::from_value(json["language"].clone()).map_err(|e| e.to_string())?;
for c in json["characters"]
.as_array()
.ok_or("HUD characters missing")?
{
if c["type_name"] != "font" {
continue;
}
let name = c["font"]["name"].as_str().ok_or("HUD font name missing")?;
let asset = &json["fonts"][name];
let d = &asset["definition"];
let glyphs: Vec<Glyph> =
serde_json::from_value(d["glyphs"].clone()).map_err(|e| e.to_string())?;
let layout = &json["font_mappings"][name]["native_layout"];
let f = |key: &str| {
layout[key]
.as_f64()
.map(|v| v as f32)
.ok_or_else(|| format!("Missing native font {name}.{key}"))
};
let mut font = Font {
texture: asset["texture"]
.as_str()
.ok_or("Font texture missing")?
.into(),
size: [
d["textures"][0]["width"]
.as_u64()
.ok_or("Font width missing")? as u32,
d["textures"][0]["height"]
.as_u64()
.ok_or("Font height missing")? as u32,
],
scale: [f("ScaleX")?, f("ScaleY")?],
offset: [f("OffsetX")?, f("OffsetY")?],
ascent: d["metrics"]["Ascent"]
.as_f64()
.ok_or("Font ascent missing")? as f32,
glyphs: BTreeMap::new(),
};
for mapping in d["characters"]
.as_array()
.ok_or("Font character map missing")?
{
let index = mapping["glyph_index"]
.as_u64()
.ok_or("Invalid font glyph index")? as usize;
let glyph = glyphs
.iter()
.find(|g| g.glyph_index == index)
.ok_or("Absent mapped glyph")?;
font.glyphs.insert(
mapping["codepoint"]
.as_u64()
.ok_or("Invalid font codepoint")? as u32,
glyph.clone(),
);
}
out.fonts.insert(
c["id"].as_i64().ok_or("Invalid font character id")? as i32,
font,
);
}
Ok(out)
}
pub fn localize(&self, text: &str) -> String {
if let Some(literal) = text.strip_prefix('#') {
return literal.into();
}
self.language
.get(text)
.cloned()
.unwrap_or_else(|| text.into())
}
}
impl Font {
pub fn glyph(&self, c: char) -> Option<&Glyph> {
self.glyphs
.get(&(c as u32))
.or_else(|| self.glyphs.get(&65535))
}
pub fn width(&self, text: &str, height: f32) -> f32 {
text.chars()
.filter_map(|c| self.glyph(c))
.fold(0.0, |x, g| g.x_advance.mul_add(self.scale[0] * height, x))
}
}
+70 -3
View File
@@ -114,10 +114,14 @@ struct Function {
struct Scope {
this: usize,
local_definitions: bool,
locals: BTreeMap<String, Value>,
}
pub trait Host {
fn property_changed(&mut self, _vm: &mut Vm, _object: usize, _key: &str) -> Result<(), String> {
Ok(())
}
fn call(
&mut self,
vm: &mut Vm,
@@ -129,6 +133,7 @@ pub trait Host {
#[derive(Default)]
pub struct Vm {
pub objects: Vec<Object>,
free_objects: Vec<usize>,
functions: Vec<Function>,
pub global: usize,
remaining: usize,
@@ -151,6 +156,14 @@ impl Vm {
vm
}
pub fn object(&mut self, kind: ObjectKind) -> usize {
if let Some(id) = self.free_objects.pop() {
self.objects[id] = Object {
kind,
fields: BTreeMap::new(),
prototype: None,
};
return id;
}
let id = self.objects.len();
self.objects.push(Object {
kind,
@@ -159,6 +172,45 @@ impl Vm {
});
id
}
/// Collect only between updates. Host-owned movie handles are explicit
/// roots; script references and prototypes preserve retired clips when
/// they remain reachable. Stable handles never move during collection.
pub fn collect(&mut self, host_roots: impl IntoIterator<Item = usize>) -> Result<(), String> {
if self.depth != 0 {
return Err("Cannot collect during an APT call".into());
}
let mut marked = vec![false; self.objects.len()];
let mut pending: Vec<_> = host_roots.into_iter().collect();
pending.push(self.global);
while let Some(id) = pending.pop() {
let seen = marked.get_mut(id).ok_or("Invalid APT collection root")?;
if *seen {
continue;
}
*seen = true;
let object = &self.objects[id];
if let Some(id) = object.prototype {
pending.push(id);
}
for value in object.fields.values() {
if let Value::Object(id) = value {
pending.push(*id);
}
}
}
self.free_objects.clear();
for (id, live) in marked.into_iter().enumerate() {
if !live {
self.objects[id] = Object {
kind: ObjectKind::Plain,
fields: BTreeMap::new(),
prototype: None,
};
self.free_objects.push(id);
}
}
Ok(())
}
pub fn set(
&mut self,
object: usize,
@@ -219,6 +271,7 @@ impl Vm {
&mut Vec::new(),
&mut Scope {
this: object,
local_definitions: false,
locals: BTreeMap::new(),
},
host,
@@ -261,6 +314,7 @@ impl Vm {
let mut reg = 1;
let mut scope = Scope {
this,
local_definitions: true,
locals: BTreeMap::new(),
};
if f.code.flags & 2 == 0 {
@@ -351,6 +405,17 @@ impl Vm {
};
match op {
0 => break,
0x70 => stack.push(Value::Object(scope.this)),
0xa1 => stack.push(Value::Text(
i.operand
.as_str()
.ok_or("APT string operand missing")?
.into(),
)),
0xa4 => stack.push(self.variable(
scope,
i.operand.as_str().ok_or("APT variable operand missing")?,
)),
0x06 | 0x07 => {
host.call(
self,
@@ -426,7 +491,8 @@ impl Vm {
let k = pop(&mut stack)?.text();
let o = pop(&mut stack)?;
if let Value::Object(id) = o {
self.set(id, k, v)?;
self.set(id, &k, v)?;
host.property_changed(self, id, &k)?;
}
}
0x1c => {
@@ -436,10 +502,11 @@ impl Vm {
0x1d | 0x3c => {
let v = pop(&mut stack)?;
let k = pop(&mut stack)?.text();
if op == 0x3c || scope.locals.contains_key(&k) {
if (op == 0x3c && scope.local_definitions) || scope.locals.contains_key(&k) {
scope.locals.insert(k, v);
} else {
self.set(scope.this, k, v)?;
self.set(scope.this, &k, v)?;
host.property_changed(self, scope.this, &k)?;
}
}
0x3a => {
+9
View File
@@ -0,0 +1,9 @@
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
struct ColorTransform { multiply: vec4<f32>, add: vec4<f32> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> color: ColorTransform;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var atlas: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var atlas_sampler: sampler;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
return clamp(textureSample(atlas, atlas_sampler, in.uv) * color.multiply + color.add, vec4(0.0), vec4(1.0));
}
+249
View File
@@ -0,0 +1,249 @@
//! Native HUD bindings and the original ActionScript-driven movie lifecycle.
use crate::{
apt_movie::Movie,
apt_vm::{Host, ObjectKind, Value, Vm},
};
#[derive(Clone, Debug, PartialEq)]
pub struct Input {
pub sequence_score: i32,
pub line_score: i32,
pub sequence_timer: i32,
pub line_time: f32,
pub line_capacity: f32,
pub multiplier: f32,
pub clean: bool,
pub sketchy: bool,
pub stance: [bool; 4],
pub trick_name: String,
pub trick_metrics: [Value; 5],
pub context_tricks: Vec<Value>,
}
pub struct Bindings {
pub movie: Movie,
pub input: Input,
random: u32,
}
pub struct Runtime {
pub vm: Vm,
pub bindings: Bindings,
pub controller: usize,
}
fn array(vm: &mut Vm, values: impl IntoIterator<Item = Value>) -> Result<Value, String> {
let object = vm.object(ObjectKind::Plain);
let mut n = 0;
for value in values {
vm.set(object, n.to_string(), value)?;
n += 1;
}
vm.set(object, "length", Value::Number(n as f64))?;
Ok(Value::Object(object))
}
fn score(value: i32) -> String {
let digits = value.unsigned_abs().to_string();
let mut out = String::new();
if value < 0 {
out.push('-');
}
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
out
}
impl Host for Bindings {
fn property_changed(&mut self, vm: &mut Vm, object: usize, key: &str) -> Result<(), String> {
if key == "text" || key == "autoSize" {
self.movie.text_changed(vm, object)?;
}
Ok(())
}
fn call(
&mut self,
vm: &mut Vm,
object: usize,
method: &str,
args: Vec<Value>,
) -> Result<Value, String> {
if self.movie.method(vm, object, method, &args)? {
return Ok(Value::Undefined);
}
let native = match vm.objects.get(object).map(|o| &o.kind) {
Some(ObjectKind::Native(name)) => name.as_str(),
_ => "global",
};
match (native, method) {
("Math", "floor") => Ok(Value::Number(
args.first().ok_or("Math.floor argument")?.number().floor(),
)),
("Math", "random") => {
// Presentation-only random source; never influences scoring.
self.random ^= self.random << 13;
self.random ^= self.random >> 17;
self.random ^= self.random << 5;
Ok(Value::Number(self.random as f64 / 4294967296.0))
}
// The HUD never enumerates prototype properties. Marking their
// enumeration flags therefore leaves its observable fields intact.
("global", "ASSetPropFlags") => Ok(Value::Undefined),
("FELanguage", "GetLanguage") => Ok(Value::Text("english".into())),
("Tricks", "GetLineTimerMaxPoints") => {
Ok(Value::Number(self.input.line_capacity as f64))
}
//825C2910 truncates manager+32 to integer;825C28F8 returns+192.
("Tricks", "GetLineTimeRemaining") => {
Ok(Value::Number(self.input.line_time.trunc() as f64))
}
("Tricks", "GetLineScore") => Ok(Value::Number(self.input.line_score as f64)),
("HUDComponents", "TrickDisplay_GetSequenceMultiplier") => {
Ok(Value::Number(self.input.multiplier as f64))
}
("Tricks", "GetGeneralInfo") => array(
vm,
[
Value::Number(self.input.sequence_score as f64),
Value::Text(score(self.input.sequence_score)),
Value::Bool(self.input.clean),
Value::Bool(self.input.sketchy),
Value::Number(self.input.sequence_timer as f64),
Value::Number(self.input.line_score as f64),
Value::Text(score(self.input.line_score)),
],
),
("Tricks", "GetCurrentTrickStance") => array(vm, self.input.stance.map(Value::Bool)),
("Tricks", "GetCurrentTrickName") => Ok(Value::Text(self.input.trick_name.clone())),
("Tricks", "GetCurrentTrickMetrics") => array(vm, self.input.trick_metrics.clone()),
("Tricks", "TrickDisplay_GetAllTrickData") => {
array(vm, self.input.context_tricks.clone())
}
_ => Err(format!(
"Unimplemented original HUD binding {native}.{method}"
)),
}
}
}
impl Runtime {
pub fn load(json: &serde_json::Value, input: Input) -> Result<Self, String> {
let mut vm = Vm::new();
for name in ["MovieClip", "Tricks", "FELanguage", "HUDComponents", "Math"] {
let object = vm.object(ObjectKind::Native(name.into()));
let prototype = vm.object(ObjectKind::Plain);
vm.set(object, "prototype", Value::Object(prototype))?;
vm.set(vm.global, name, Value::Object(object))?;
}
vm.set(vm.global, "Screen_EdgeOffset", Value::Number(0.0))?;
let mut bindings = Bindings {
movie: Movie::load(json)?,
input,
random: 0x9e3779b9,
};
let initial: Vec<_> = bindings
.movie
.characters
.values()
.flat_map(|c| &c.frames)
.flat_map(|f| &f.controls)
.filter(|c| c.type_name == "do_init_action")
.map(|c| c.actions_offset)
.collect();
for offset in initial {
let code = bindings.movie.actions[&offset.to_string()].clone();
vm.run(&code, &mut bindings)?;
}
vm.begin_update();
bindings.movie.initialize(&mut vm)?;
let mut runtime = Self {
vm,
bindings,
controller: usize::MAX,
};
runtime.drain()?;
let Value::Object(controller) = runtime.vm.get(runtime.bindings.movie.root, "screen")
else {
return Err("Original HUD controller was not constructed".into());
};
runtime.controller = controller;
runtime.vm.call_method(
controller,
"ScreenShow",
vec![Value::Bool(true)],
&mut runtime.bindings,
)?;
runtime.drain()?;
Ok(runtime)
}
fn drain(&mut self) -> Result<(), String> {
let mut calls = 0;
while let Some((object, offset)) = self.bindings.movie.pending.pop_front() {
calls += 1;
if calls > 4096 {
return Err("HUD frame action limit".into());
}
if !self.bindings.movie.instances.contains_key(&object) {
continue;
}
let code = self
.bindings
.movie
.actions
.get(&offset.to_string())
.ok_or("Missing HUD action block")?
.clone();
self.vm.run_on(object, &code, &mut self.bindings)?;
}
Ok(())
}
pub fn update(
&mut self,
input: Input,
new_trick: bool,
modified_trick: bool,
close_tricks: bool,
) -> Result<(), String> {
self.vm.begin_update();
let previous = self.bindings.input.clone();
self.bindings.input = input;
let mut methods = Vec::new();
if previous.multiplier != self.bindings.input.multiplier {
methods.push("UpdateMultiplier");
}
if 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
{
methods.push("UpdateTrickScoring");
}
if previous.stance != self.bindings.input.stance {
methods.push("UpdateStance");
}
if new_trick {
methods.push("RefreshTrickName");
} else if modified_trick {
methods.push("CurrentTrickModified");
}
if close_tricks {
methods.push("CloseTrickDisplay");
}
for method in methods {
self.vm
.call_method(self.controller, method, vec![], &mut self.bindings)?;
self.drain()?;
}
self.vm.call_method(
self.controller,
"UpdateLineDisplay",
vec![],
&mut self.bindings,
)?;
self.drain()?;
self.bindings.movie.advance(&mut self.vm)?;
self.drain()?;
self.vm
.collect(self.bindings.movie.instances.keys().copied())?;
Ok(())
}
}
+6
View File
@@ -1,6 +1,12 @@
mod animation;
mod apt_vm;
mod apt_display;
mod apt_movie;
mod apt_text;
mod apt_scene;
mod hud_runtime;
mod scoring_runtime;
mod scoring_hud;
mod animation_pose;
mod app;
mod assets;
@@ -27,6 +27,7 @@ pub(crate) fn advance(
graphs: &StockGraphs,
profile: &AnimationProfile,
) -> Result<AnimationPhaseOutput, String> {
skater.animation.motion.score_packet = Default::default();
let p = &skater.player_input.processed;
let physical = &skater.player_input.physical;
let deck = physics.board.part_transforms()[BodyId::Deck.index()];
+17
View File
@@ -222,6 +222,23 @@ pub(super) fn advance(
events,
});
camera_output::advance(physics, skater, &feedback, camera)?;
let score = &skater.animation.motion.score_packet;
let deck_frame = physics.board.part_transforms()[BodyId::Deck.index()];
let position = deck.rates.position;
let velocity = deck.rates.linear_velocity;
let filtered = skater.player_state.filtered_output;
skater.scoring.advance(crate::scoring_runtime::Frame {
tick: tick as u32, dt: simulation.time_step,
category: filtered.map_or(Default::default(), |f|f.category),
state: skater.player_state.current() as u32,
descriptor: score.trick.or_else(||score.grab.map(|g|g.0)),
grind_id: filtered.map_or(-1, |f|f.grind.scorable_id), flags:score.flags,
position:[position.x,position.y,position.z], velocity:[velocity.x,velocity.y,velocity.z],
forward:deck_frame.basis.columns[2],
switch:skater.animation.packet.riding_switch,fakie:skater.animation.packet.riding_fakie,
nollie:skater.animation.packet.weight_forwards,body_flip:skater.player_input.physical.air.flag_441!=0,
landing:skater.landing_quality,teleported,reverting:skater.player_state.state_flags[70-52],
})?;
super::climbing::approach::advance(physics, skater, controls);
skater.animation_input.finish_output_publication();
skater.player_input.player.update_count_1316 =
+2
View File
@@ -27,6 +27,7 @@ use std::path::Path;
#[derive(Resource)]
pub(crate) struct SkaterRuntime {
pub scoring: crate::scoring_runtime::Runtime,
pub climbing: super::climbing::Runtime,
/// Completed physical pose in native animation space, read by rendering.
pub render_pose: Vec<skate_core::animation::output::NativeMatrix>,
@@ -191,6 +192,7 @@ impl SkaterRuntime {
&data, asset_root, &animation.evaluator.frames.source_sha256,
)?;
Ok(Self {
scoring: crate::scoring_runtime::Runtime::load(&data)?,
climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?,
offboard,
render_pose: initial_hierarchy,
+293
View File
@@ -0,0 +1,293 @@
//! Original APT HUD rendered independently of the world's resolution scale.
use crate::{apt_scene, config::Config, hud_runtime, physics::SkaterRuntime};
use bevy::{
asset::{RenderAssetUsages, embedded_asset},
camera::{RenderTarget, visibility::RenderLayers},
prelude::*,
render::render_resource::{
AsBindGroup, Extent3d, PrimitiveTopology, ShaderType, TextureDimension, TextureFormat,
},
shader::ShaderRef,
sprite_render::{AlphaMode2d, Material2d, Material2dPlugin, MeshMaterial2d},
};
use std::{collections::BTreeMap, path::PathBuf};
#[derive(Clone, Copy, Debug, ShaderType)]
struct ColorTransform {
multiply: Vec4,
add: Vec4,
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct HudMaterial {
#[uniform(0)]
color: ColorTransform,
#[texture(1)]
#[sampler(2)]
atlas: Handle<Image>,
}
impl Material2d for HudMaterial {
fn fragment_shader() -> ShaderRef {
"embedded://skate3rust/hud_render.wgsl".into()
}
fn alpha_mode(&self) -> AlphaMode2d {
AlphaMode2d::Blend
}
}
struct Slot {
entity: Entity,
mesh: Handle<Mesh>,
material: Handle<HudMaterial>,
}
#[derive(Resource)]
struct Hud {
runtime: hud_runtime::Runtime,
source: serde_json::Value,
shapes: apt_scene::Shapes,
textures: BTreeMap<String, Handle<Image>>,
slots: Vec<Slot>,
generation: u64,
failed: bool,
}
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)
.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(
mut commands: Commands,
config: Res<Config>,
skater: Res<SkaterRuntime>,
cameras: Query<Entity, With<IsDefaultUiCamera>>,
mut images: ResMut<Assets<Image>>,
) {
let root = std::env::var_os("SKATE_SCORING_HUD_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| config.asset_root.join("private/hud"));
let result = (|| -> Result<Hud, String> {
let source: serde_json::Value = serde_json::from_slice(
&std::fs::read(root.join("runtime/trickdisplay.json"))
.map_err(|e| format!("{}: {e}", root.display()))?,
)
.map_err(|e| e.to_string())?;
let runtime = hud_runtime::Runtime::load(&source, skater.scoring.hud_input())?;
let shapes: apt_scene::Shapes =
serde_json::from_value(source["shapes"].clone()).map_err(|e| e.to_string())?;
let mut files = BTreeMap::new();
for shape in shapes.values().flatten() {
files.insert(
shape.texture.rgba.clone(),
[shape.texture.width, shape.texture.height],
);
}
for font in runtime.bindings.movie.text_assets.fonts.values() {
files.insert(font.texture.clone(), font.size);
}
let mut textures = BTreeMap::new();
for (path, size) in files {
let bytes = std::fs::read(root.join(&path)).map_err(|e| format!("HUD {path}: {e}"))?;
if bytes.len() != size[0] as usize * size[1] as usize * 4 {
return Err(format!("Invalid HUD texture size {path}"));
}
let image = Image::new(
Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
TextureDimension::D2,
bytes,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::RENDER_WORLD,
);
textures.insert(path, images.add(image));
}
Ok(Hud {
runtime,
source,
shapes,
textures,
slots: Vec::new(),
generation: 0,
failed: false,
})
})();
match result {
Ok(hud) => {
let target = images.add(Image::new_target_texture(
1280,
720,
TextureFormat::Rgba8UnormSrgb,
None,
));
commands.spawn((
Camera2d,
Camera {
order: -1,
clear_color: ClearColorConfig::Custom(Color::NONE),
..default()
},
RenderTarget::Image(target.clone().into()),
RenderLayers::layer(31),
Msaa::Off,
));
let Ok(output) = cameras.single() else {
error!("Original HUD requires the presentation camera");
return;
};
commands.spawn((
ImageNode::new(target),
UiTargetCamera(output),
GlobalZIndex(1),
Pickable::IGNORE,
Node {
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..default()
},
));
commands.insert_resource(hud);
info!("Original scoring HUD loaded");
}
Err(error) => error!("Original scoring HUD could not load: {error}"),
}
}
fn reset(
hud: Option<ResMut<Hud>>,
skater: Res<SkaterRuntime>,
map: Res<crate::map_transition::CurrentMap>,
) {
let Some(mut hud) = hud else {
return;
};
if hud.generation == map.generation {
return;
}
match hud_runtime::Runtime::load(&hud.source, skater.scoring.hud_input()) {
Ok(runtime) => {
hud.runtime = runtime;
hud.generation = map.generation;
hud.failed = false;
}
Err(error) => {
error!("Original scoring HUD reset: {error}");
hud.failed = true;
}
}
}
fn advance(
hud: Option<ResMut<Hud>>,
skater: Res<SkaterRuntime>,
map: Res<crate::map_transition::CurrentMap>,
) {
let Some(mut hud) = hud else {
return;
};
if hud.failed || hud.generation != map.generation {
return;
}
if let Err(error) = hud.runtime.update(
skater.scoring.hud_input(),
skater.scoring.new_trick,
skater.scoring.modified_trick,
skater.scoring.close_tricks,
) {
error!("Original scoring HUD stopped: {error}");
hud.failed = true;
}
}
fn render(
mut commands: Commands,
hud: Option<ResMut<Hud>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<HudMaterial>>,
) {
let Some(mut hud) = hud else {
return;
};
let draws = if hud.failed {
Vec::new()
} else {
match apt_scene::draw(&hud.runtime.bindings.movie, &hud.runtime.vm, &hud.shapes) {
Ok(draws) => draws,
Err(error) => {
error!("Original HUD geometry: {error}");
hud.failed = true;
Vec::new()
}
}
};
for (index, draw) in draws.iter().enumerate() {
let Some(texture) = hud.textures.get(&draw.texture).cloned() else {
continue;
};
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_POSITION,
draw.vertices
.iter()
.map(|v| [v.position[0] - 640., 360. - v.position[1], 0.])
.collect::<Vec<_>>(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
draw.vertices.iter().map(|v| v.uv).collect::<Vec<_>>(),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_NORMAL,
vec![[0., 0., 1.]; draw.vertices.len()],
);
let material = HudMaterial {
color: ColorTransform {
multiply: draw.multiply.into(),
add: draw.add.into(),
},
atlas: texture,
};
if index == hud.slots.len() {
let mesh = meshes.add(mesh);
let material = materials.add(material);
let entity = commands
.spawn((
Mesh2d(mesh.clone()),
MeshMaterial2d(material.clone()),
Transform::from_xyz(0., 0., index as f32 * 0.01),
RenderLayers::layer(31),
))
.id();
hud.slots.push(Slot {
entity,
mesh,
material,
});
} else {
let slot = &hud.slots[index];
if let Some(old) = meshes.get_mut(&slot.mesh) {
*old = mesh;
}
if let Some(old) = materials.get_mut(&slot.material) {
*old = material;
}
commands.entity(slot.entity).insert(Visibility::Visible);
}
}
for slot in &hud.slots[draws.len()..] {
commands.entity(slot.entity).insert(Visibility::Hidden);
}
}
+597
View File
@@ -0,0 +1,597 @@
//! ScoreModule integration. The simulation owns recognition and accounting;
//! the APT movie only consumes the resulting publication.
use crate::{apt_vm::Value, hud_runtime};
use skate_core::{
animation::output::attributes::AttributeName,
physics::filtered_state::FilteredCategory,
scoring::{
carrier::{Carrier, delay_ticks},
conversions::LINKS,
session::Session,
},
};
use skate_data::{collections::Collections, scoring::ScoringData};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Collector {
None,
Ground,
Air,
Grind,
Offboard,
Special,
}
pub(crate) struct Frame {
pub tick: u32,
pub dt: f32,
pub category: FilteredCategory,
pub state: u32,
pub descriptor: Option<AttributeName>,
pub grind_id: i32,
pub flags: u32,
pub position: [f32; 3],
pub velocity: [f32; 3],
pub forward: [f32; 3],
pub switch: bool,
pub fakie: bool,
pub nollie: bool,
pub body_flip: bool,
pub landing: skate_core::animation::landing_quality::Output,
pub teleported: bool,
pub reverting: bool,
}
pub(crate) struct Runtime {
pub data: ScoringData,
pub session: Session,
collector: Collector,
carriers: [Option<Carrier>; 4],
held: [f32; 4],
distance: [f32; 4],
metric_rewards: [f32; 4],
start: [f32; 3],
previous: [f32; 3],
previous_heading: f32,
spin: f32,
peak: f32,
air_factor: f32,
air_repetition: f32,
air_repetition_set: bool,
grab_chain: u32,
air_metrics: [f32; 5],
landing_countdown: u32,
idle_ticks: u32,
collector_ticks: u32,
manual_revert_ticks: u32,
revert_id: Option<usize>,
sequence_active: bool,
sequence_score: f32,
trick_name: String,
stance: [bool; 4],
clean: bool,
sketchy: bool,
pub new_trick: bool,
pub modified_trick: bool,
pub close_tricks: bool,
}
impl Runtime {
pub fn load(data: &Collections) -> Result<Self, String> {
Ok(Self {
data: ScoringData::load(data)?,
session: Session::default(),
collector: Collector::None,
carriers: std::array::from_fn(|_| None),
held: [0.; 4],
distance: [0.; 4],
metric_rewards: [0.; 4],
start: [0.; 3],
previous: [0.; 3],
previous_heading: 0.,
spin: 0.,
peak: 0.,
air_factor: 1.,
air_repetition: 1.,
air_repetition_set: false,
grab_chain: 0,
air_metrics: [0.; 5],
landing_countdown: 0,
idle_ticks: 0,
collector_ticks: 0,
manual_revert_ticks: 0,
revert_id: None,
sequence_active: false,
sequence_score: 0.,
trick_name: String::new(),
stance: [false; 4],
clean: false,
sketchy: false,
new_trick: false,
modified_trick: false,
close_tricks: false,
})
}
pub fn hud_input(&self) -> hud_runtime::Input {
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,
line_capacity: self.data.line_capacity,
multiplier: self.session.combo.multiplier,
clean: self.clean,
sketchy: self.sketchy,
stance: self.stance,
trick_name: self.trick_name.clone(),
trick_metrics: [
Value::Text(self.trick_name.clone()),
Value::Bool(false),
Value::Bool(false),
Value::Bool(false),
Value::Bool(false),
],
context_tricks: Vec::new(),
}
}
fn penalty(&self, id: usize) -> f32 {
let Some(d) = self.data.by_id(id) else {
return 1.;
};
if d.metadata.repetition_applies() {
self.data.repetition.evaluate(
self.session
.holder
.repetition_count(d.metadata)
.unwrap_or(0) as f32,
)
} else {
1.
}
}
fn finish(&mut self, slot: usize, complete: bool, keep_metric: bool) {
if let Some(mut carrier) = self.carriers[slot].take() {
if complete {
carrier.complete(self.data.unannounced_factor);
if self.collector == Collector::Air {
self.session
.holder
.end_trick(carrier.scorable, carrier.reward);
} else {
let metric = self.collector == Collector::Grind
|| self.collector == Collector::Ground && slot < 3;
if !(keep_metric && self.collector == Collector::Ground) {
let reward = if keep_metric {
0.
} else if metric {
if carrier.scorable.score_type == 9 {
carrier.reward.max(self.metric_rewards[slot])
} else {
self.metric_rewards[slot].max(0.)
}
} else {
carrier.reward
};
self.session.holder.credit_trick(carrier.scorable, reward);
}
}
}
}
if !keep_metric {
self.held[slot] = 0.;
self.distance[slot] = 0.;
self.metric_rewards[slot] = 0.;
}
}
fn carrier(&mut self, slot: usize, id: Option<usize>, f: &Frame) -> Result<(), String> {
if self.carriers[slot].as_ref().map(|c| c.scorable.id) != id {
let conversion = id.filter(|new| {
self.collector == Collector::Air
&& self.carriers[slot].is_some()
&& LINKS[*new].0 >= 0
});
let chained_grab = self.collector == Collector::Air
&& id
.and_then(|id| self.data.by_id(id))
.is_some_and(|d| d.metadata.class == 2)
&& self.carriers[slot]
.as_ref()
.is_some_and(|c| c.scorable.class == 2 && !c.announced);
let previous_tick = self.carriers[slot].as_ref().map(|c| c.start_tick);
let keep_metric = id.is_some()
&& (self.collector == Collector::Grind
|| self.collector == Collector::Ground && slot == 0);
if conversion.is_none() {
if chained_grab {
self.carriers[slot] = None;
self.grab_chain += 1;
} else {
self.finish(slot, true, keep_metric);
self.grab_chain = 0;
}
}
if let Some(id) = id {
let d = self
.data
.by_id(id)
.ok_or_else(|| format!("Missing native scorable {id}"))?;
let factor = self.penalty(id)
* if self.collector == Collector::Air {
self.air_factor
} else {
1.
};
let mut carrier = Carrier::new(
d.metadata,
d.points,
factor,
self.data.announcement.evaluate(d.points as f32),
if chained_grab {
previous_tick.unwrap_or(f.tick)
} else {
f.tick
},
delay_ticks(
d.completion_delay,
if self.grab_chain > 1 {
self.data.collector.scalar(0x698)
} else {
0.
},
),
f.switch,
f.fakie,
);
if let Some(old) = self.carriers[slot].as_mut() {
old.convert_to(&mut carrier, self.data.unannounced_factor);
self.modified_trick = true;
}
if conversion.is_some() {
self.trick_name = d.label.clone();
}
self.carriers[slot] = Some(carrier);
self.sequence_active = true;
self.idle_ticks = 0;
}
}
let penalty = self.carriers[slot]
.as_ref()
.map(|c| self.penalty(c.scorable.id))
.unwrap_or(1.);
if let Some(c) = self.carriers[slot].as_mut() {
if c.announce(f.tick, self.data.unannounced_factor) {
let d = self
.data
.by_id(c.scorable.id)
.ok_or("Missing announced scorable")?;
self.trick_name = d.label.clone();
self.stance = [f.switch, f.fakie, f.nollie, false];
self.new_trick = true;
if self.collector == Collector::Air && !self.air_repetition_set {
self.air_repetition = penalty;
self.air_repetition_set = true;
}
}
if c.announced {
self.held[slot] += f.dt;
let previous_distance = self.distance[slot];
let displacement =
std::array::from_fn::<_, 3, _>(|i| f.position[i] - self.previous[i]);
let delta = if self.collector == Collector::Ground && slot == 1 {
displacement
.iter()
.zip(f.forward)
.map(|(x, y)| x * y)
.sum::<f32>()
} else {
(displacement.iter().map(|x| x * x).sum::<f32>()).sqrt()
};
if delta > f32::from_bits(0x3ba3d70a) {
self.distance[slot] += delta;
}
let curve = match self.collector {
Collector::Air if c.scorable.class == 2 => Some(0x460),
Collector::Offboard => Some(0x140),
_ => None,
};
if let Some(curve) = curve {
c.reward += (self.data.collector.curve(curve, self.held[slot]) * f.dt)
* c.announcement_threshold;
}
let curve = match (self.collector, c.scorable.score_type) {
(Collector::Ground, 9) => Some((0x000, 0x050)),
(Collector::Ground, 8) => Some((0x0f0, 0x0a0)),
(Collector::Ground, _) if slot == 2 => Some((0x230, 0x1e0)),
(Collector::Grind, _) => Some((0x2d0, 0x280)),
_ => None,
};
if let Some((distance, time)) = curve {
let delta = (self.data.collector.curve(distance, self.distance[slot])
- self.data.collector.curve(distance, previous_distance))
.max(0.);
self.metric_rewards[slot] = (self
.data
.collector
.curve(time, self.held[slot])
.mul_add(f.dt, delta))
.mul_add(c.announcement_threshold, self.metric_rewards[slot]);
}
}
}
Ok(())
}
pub fn advance(&mut self, f: Frame) -> Result<(), String> {
self.new_trick = false;
self.modified_trick = false;
self.close_tricks = false;
let descriptor = f
.descriptor
.and_then(|name| self.data.by_name(name))
.filter(|d| !(f.flags & 0x02000000 != 0 && [67, 68, 69].contains(&d.metadata.id)))
.map(|d| (d.metadata.id, d.metadata.class, d.metadata.score_type));
let mut next = match f.category {
FilteredCategory::Ground => Collector::Ground,
FilteredCategory::Air => Collector::Air,
FilteredCategory::Grind => Collector::Grind,
FilteredCategory::Offboard | FilteredCategory::OffboardAir => Collector::Offboard,
_ => Collector::None,
};
if f.state == 600 && matches!(next, Collector::Ground | Collector::Air) {
next = Collector::Special;
}
if next == Collector::Air
&& self.collector == Collector::Ground
&& descriptor.is_some_and(|d| d.1 == 0)
{
next = Collector::Ground;
}
if next == Collector::Ground
&& self.collector == Collector::Air
&& descriptor.is_some_and(|d| d.1 == 3)
{
next = Collector::Air;
}
if f.teleported {
next = Collector::None;
}
if next != self.collector {
let complete = next != Collector::None;
let previous_type = self
.carriers
.iter()
.flatten()
.last()
.map(|c| c.scorable.score_type);
for slot in 0..4 {
self.finish(slot, complete, false);
}
if self.collector == Collector::Air {
if complete {
for (i, reward) in self.air_metrics.into_iter().enumerate() {
if let Some(d) = self.data.by_id(129 + i) {
self.session.holder.end_trick(d.metadata, reward);
}
}
}
self.session.holder.finish_collector();
self.landing_countdown = 2;
}
self.collector = next;
self.start = f.position;
self.peak = f.position[1];
self.spin = 0.;
self.previous_heading = f.forward[0].atan2(f.forward[2]);
self.air_metrics = [0.; 5];
self.air_repetition = 1.;
self.air_repetition_set = false;
self.air_factor = 1.;
self.grab_chain = 0;
self.collector_ticks = 0;
self.manual_revert_ticks = 0;
self.revert_id = None;
if next == Collector::Air {
self.session.holder.reward_sequence(1.);
self.landing_countdown = 0;
if previous_type == Some(8) {
self.air_factor *= self.data.collector.scalar(0x68c);
}
if previous_type == Some(5) {
self.air_factor *= self.data.collector.scalar(0x690);
}
if f.velocity[0] * f.velocity[0] + f.velocity[2] * f.velocity[2]
< self.data.collector.scalar(0x66c).powi(2)
{
self.air_factor *= self.data.collector.scalar(0x688);
}
if f.switch && !f.fakie {
self.air_factor *= self.data.collector.scalar(0x684);
}
if f.fakie && !f.switch {
self.air_factor *= self.data.collector.scalar(0x694);
}
}
}
let mut ids = [None; 4];
self.collector_ticks = self.collector_ticks.saturating_add(1);
match self.collector {
Collector::Ground => {
ids[0] = if f.flags & 0x80000000 != 0 {
Some(0)
} else if f.flags & 0x40000000 != 0 {
Some(1)
} else {
None
};
ids[1] = if f.flags & 0x08000000 != 0 {
Some(2)
} else if f.flags & 0x04000000 != 0 {
Some(3)
} else {
None
};
if ids[1].is_some() && f.reverting {
self.manual_revert_ticks += 1;
ids[1] = None;
} else {
self.manual_revert_ticks = 0;
}
if self.manual_revert_ticks > 6 {
ids[1] = None;
}
if ids[1].is_some() {
if let Some(c) = &self.carriers[1] {
ids[1] = Some(c.scorable.id);
}
}
ids[2] = descriptor
.filter(|d| d.0 == 60 && f.flags & 0x02000000 != 0)
.map(|d| d.0);
if f.flags & 0x20000000 != 0 {
self.revert_id = Some(4);
} else if f.flags & 0x10000000 != 0 {
self.revert_id = Some(5);
}
ids[3] = if f.reverting { self.revert_id } else { None };
}
Collector::Air => ids[0] = descriptor.filter(|d| d.2 != 5).map(|d| d.0),
Collector::Grind => ids[0] = (f.grind_id >= 0).then_some(f.grind_id as usize),
Collector::Offboard => ids[0] = descriptor.filter(|d| d.2 == 6).map(|d| d.0),
Collector::Special => ids[0] = descriptor.filter(|d| d.0 == 234).map(|d| d.0),
Collector::None => {}
}
for (slot, id) in ids.into_iter().enumerate() {
self.carrier(slot, id, &f)?;
}
if self.collector == Collector::Air {
if self.collector_ticks > 5 || f.flags & 0x01000000 != 0 {
self.sequence_active = true;
}
let heading = f.forward[0].atan2(f.forward[2]);
let delta = (heading - self.previous_heading + std::f32::consts::PI)
.rem_euclid(std::f32::consts::TAU)
- std::f32::consts::PI;
self.spin += delta;
self.previous_heading = heading;
self.peak = self.peak.max(f.position[1]);
let dx = f.position[0] - self.start[0];
let dz = f.position[2] - self.start[2];
let scale = self.air_factor * self.air_repetition;
self.air_metrics[0] =
self.data.collector.curve(0x3c0, (dx * dx + dz * dz).sqrt()) * scale;
self.air_metrics[1] =
self.data.collector.curve(0x410, self.peak - self.start[1]) * scale;
self.air_metrics[2] = self
.data
.collector
.curve(0x320, f.position[1] - self.start[1])
* scale;
let turns =
((self.spin.to_degrees().abs() + self.data.collector.scalar(0x63c)) / 180.) as i32;
self.air_metrics[3] = self.data.collector.curve(0x370, (turns * 180) as f32) * scale;
if f.body_flip
&& self.carriers[0]
.as_ref()
.is_some_and(|c| c.announced && c.scorable.class == 2)
{
self.air_metrics[4] = self.data.collector.scalar(0x640) * scale;
}
}
let active = self.carriers.iter().any(Option::is_some) || self.collector == Collector::Air;
self.idle_ticks = if active {
0
} else {
self.idle_ticks.saturating_add(1)
};
if self.session.holder.has_pending_sequence() {
if f.landing.landing_data_167 {
self.clean = f.landing.landing_type_96 == 0;
self.sketchy = f.landing.landing_type_96 == 1
&& f.landing.sideways_speed_84 > self.data.sketchy_side_speed
&& f.landing.spin_92.abs() > 0.5;
}
if self.landing_countdown == 0 {
let mut factor = 1.;
if f.switch && !f.fakie {
factor *= self.data.collector.scalar(0x61c);
}
if f.fakie && !f.switch {
factor *= self.data.collector.scalar(0x62c);
}
if self.collector == Collector::Grind {
factor *= self.data.collector.scalar(0x628);
}
if self.clean {
factor *= self.data.collector.scalar(0x630);
}
if self.sketchy {
factor *= self.data.collector.scalar(0x620);
}
self.session.holder.reward_sequence(factor);
} else {
self.landing_countdown -= 1;
}
}
let scales = match self.collector {
Collector::Air => (0x668, 0x664),
Collector::Grind => (0x618, 0x614),
Collector::Offboard => (0x608, 0x604),
_ => (0x610, 0x60c),
};
let line_scale = if active {
self.data.collector.scalar(scales.0)
} else {
1.
};
let combo_scale = if active {
self.data.collector.scalar(scales.1)
} else {
1.
};
self.session
.line
.advance(f.dt, self.data.line_drain, line_scale, active);
self.session
.combo
.timer
.advance(f.dt, self.data.combo_drain, combo_scale, active);
let bailout = self.collector == Collector::None && self.sequence_active;
if self.sequence_active && (bailout || self.idle_ticks >= 3 && self.landing_countdown == 0)
{
if bailout {
self.session.holder.cancel_pending();
}
self.sequence_score =
self.session
.publish_sequence(&self.data.session_rules(), 1., bailout, true);
self.sequence_active = false;
self.close_tricks = true;
} else if self.sequence_active {
let s = &self.session.holder.snapshot;
self.sequence_score = (s.accumulated
+ s.general_pending
+ s.fingerflip_pending
+ self
.carriers
.iter()
.enumerate()
.filter_map(|(i, c)| {
c.as_ref().map(|c| {
if self.collector == Collector::Grind
|| self.collector == Collector::Ground && i < 3
{
if c.scorable.score_type == 9 {
c.reward.max(self.metric_rewards[i])
} else {
self.metric_rewards[i]
}
} else {
c.reward
}
})
})
.sum::<f32>()
+ self.air_metrics.iter().sum::<f32>())
* self.session.combo.multiplier;
}
self.session.settle_line(f.teleported || bailout, active);
self.previous = f.position;
Ok(())
}
}
+56 -31
View File
@@ -59,40 +59,65 @@ The original movie is authored at 1280 by 720. Its score, line, stance,
multiplier and trick-name timelines must control presentation; replacing them
with a new overlay would not meet this port's requirements.
## Remaining implementation
## Production integration and validation
Collectors are not wired into the production tick yet. Recognition still
requires conditioner gates, descriptor conversion, air/ground/grind collector
transitions, spin/flip bonuses, gaps, landing and sequence publication ordering.
Announcement delay conversion uses the verified binary32 60 at `8303745C`.
The carrier's start/current tick source still needs to be connected at the
matching production phase.
The production fixed tick now supplies animation descriptors, conditioned category,
grind IDs, landing output and physical motion to `scoring_runtime`. The runtime
loads native VLT points, repetition, announcement and collector curves, maintains
carriers and continuous distance rewards, and publishes sequence/line accounting.
It is a usable integration for testing, **not a finished native-parity port**.
The HUD still needs a complete movie object hierarchy, native bindings,
timeline execution and rendering with multiplicative and additive colors.
Superclass preloads and supported action coverage also remain incomplete.
The audit's dummy host intentionally does not establish native binding parity.
The HUD executes the owned trickdisplay ActionScript and timelines, including
score, multiplier, line meter, stance and trick-name updates. It renders original
shape atlases and font glyphs through a separate 1280x720 camera with inherited
multiplicative/additive colors. Movie objects are collected and mesh/material
slots reused. Fixed updates pause with gameplay; map-generation changes recreate
the movie even while paused. Missing assets or unsupported actions are logged.
Font resolution is recovered from FontManager initialization `82808AE8`,
APT-name lookup `82809208` and loading `82809308`. VLT class
`FECFBCAF356518C4` maps the APT name (field `340832CCFD9FDEB4`, layout +8) to
`FileName` (layout +4). In particular, `FuturaOuterGlow` maps to `futurashadow`,
not the prior extractor's inferred `Futura Glow`. The prepared movie has no
unresolved font references. The manifest retains VLT record provenance and
native scale/offset fields. The community symbol calling `82809168`
GetFontByAptName is misleading: that function searches the filename column;
`82809208` searches the APT-name column.
Font resolution follows FontManager initialization `82808AE8`, APT-name lookup
`82809208` and loading `82809308`. VLT class `FECFBCAF356518C4` maps AptName to
FileName; `FuturaOuterGlow` resolves to `futurashadow`. Glyph advance and placement
use native scale/ascent metrics. English text resolves through the owned language
asset. No replacement interface or bundled game assets are introduced.
Pause, map-change generation reset and resource reuse remain to be integrated
and checked. A release compilation succeeds; its executable is copied into
the ignored task-local `logs/scoring/build` directory, with an untracked
`Launch scoring worktree.bat`. This is a compilation checkpoint, not a working
scoring/HUD feature build. No game/recomp,
controller harness, gameplay automation, `--check-assets`, or screenshot
capture has been run for this work.
Validation on 2026-09-08:
Validation so far: nine scoring unit tests pass (including unsigned clock
rollover, early completion, conversion, timer thresholds, one-time banking and
cancelled rewards); the owned-data loader audit resolves 300 definitions; the
APT data-only audit passes placement validation and constructor checks.
- All nine scoring unit tests pass, covering clock wrap, early completion,
conversion, cancellation, repetition, timer thresholds and one-time banking.
- 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.
- 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`
or screenshot capture was run. GPU rendering and interactive behavior remain
unverified until the user launches the build.
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
`775264c02f456665052355d466b99cb2e3ead1e359af3123c9a53afd68e1e182`.
## Native parity gaps
These are implementation gaps, not merely missing gameplay validation:
- Collector activation gates and exact publication timing need further porting;
the current runtime uses conditioned categories and an idle countdown.
- Air spin uses accumulated board heading rather than the complete native
transform accumulator. Body-flip direction and some landing modifiers remain
incomplete.
- Gap/context collectors and their native ground-query inputs, contextual
bonuses and off-board height rewards are not wired.
- Revert recognition depends on an unpublished physical state flag in the
current host. Full native revert scoring is not yet available.
- Trick-name spin/direction modifiers and the HUD manager's stance transitions
are incomplete. The bindings expose five metric slots but currently only
supply the base trick label and default modifier flags.
- The compact VM supports the exercised movie paths, not arbitrary APT programs.
Superclass/native constructor behavior is limited, and Math.random uses a
local presentation RNG rather than the original engine RNG stream.
Do not describe this build as fully finished or an exact native recreation.
+2
View File
@@ -86,6 +86,8 @@ def prepare(game: Path, output: Path, collections: Path):
'characters': list(bundle['characters'].values()),
'shapes': shapes, 'fonts': fonts, 'actions': blocks,
'font_mappings':mappings,
'language': {row['label'].strip(): row['value'] for row in
json.loads((output / 'metadata/languages/english_global.json').read_text(encoding='utf-8'))['entries']},
'unresolved_fonts': [family for family, asset in fonts.items() if asset is None],
}
target = output / 'runtime' / 'trickdisplay.json'