Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ flamegraph.svg
.agents/feature-checklists/
!assets/reverse-flick-vs-backflip-2026-06-26.replay
!assets/wall-aerial-ground-jump-2026-07-03.replay
!assets/ranked-standard-state-name-index-collision-2024-08-25.replay
/crates/subtr-actor-training/bindings

# Generated review-playlist artifacts: regenerable from replays via
Expand Down
7 changes: 7 additions & 0 deletions assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,10 @@ Mechanic regression fixtures are pinned on small clips by tests under
was a normal aerial launched from the floor *near* the side wall (the car
was never on the wall). Whole-replay ground truth for
`tests/wall_aerial_fixture_test.rs`.

Game-state fixture downloaded from Ballchasing:

- `ranked-standard-state-name-index-collision-2024-08-25`
- replay id: `73198d3f-d983-4507-afca-e1176a202e80`
- playlist: `ranked-standard`
- note: name-table index 67 is `Active`, which the hardcoded goal-replay state code used to match
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
73198d3f-d983-4507-afca-e1176a202e80
21 changes: 20 additions & 1 deletion js/player/src/player/adapter/SubtrActorPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,26 @@ export class SubtrActorPlayer extends EventEmitter {
if (sawCountdown && remaining === 0) return this.frameTimes[index] ?? null;
}

const firstActiveFrame = frames.findIndex((frame) => frame.replicated_game_state_name === 54);
// The state value indexes the replay's name table, so infer the live-play
// state as the most common one rather than assuming a fixed index.
const stateCounts = new Map<number, number>();
for (const frame of frames) {
const state = frame.replicated_game_state_name;
if (state != null) stateCounts.set(state, (stateCounts.get(state) ?? 0) + 1);
}
let activeState: number | null = null;
let activeStateCount = 0;
for (const [state, count] of stateCounts) {
if (count > activeStateCount) {
activeState = state;
activeStateCount = count;
}
}
if (activeState === null) return null;

const firstActiveFrame = frames.findIndex(
(frame) => frame.replicated_game_state_name === activeState,
);
return firstActiveFrame === -1 ? null : (this.frameTimes[firstActiveFrame] ?? null);
}

Expand Down
14 changes: 13 additions & 1 deletion src/processor/player_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ impl<'a> ReplayProcessor<'a> {
.copied()
}

/// Returns the replicated game-state enum value from the metadata actor.
/// Returns the raw replicated game-state value from the metadata actor.
///
/// This is an index into the replay's name table, not a stable code; use
/// [`Self::get_game_state`] to compare against `GAME_STATE_*` codes.
pub fn get_replicated_state_name(&self) -> SubtrActorResult<i32> {
get_actor_attribute_matching!(
self,
Expand All @@ -34,6 +37,15 @@ impl<'a> ReplayProcessor<'a> {
.cloned()
}

/// Returns the replicated game state as a canonical `GAME_STATE_*` code.
///
/// The replicated value is an index into this replay's name table, so the
/// same number names different states in different replays.
pub fn get_game_state(&self) -> Option<i32> {
let name_index = usize::try_from(self.get_replicated_state_name().ok()?).ok()?;
crate::stats::calculators::canonical_game_state(self.replay.names.get(name_index)?)
}

/// Returns the replicated kickoff countdown / time-remaining field.
pub fn get_replicated_game_state_time_remaining(&self) -> SubtrActorResult<i32> {
get_actor_attribute_matching!(
Expand Down
12 changes: 2 additions & 10 deletions src/processor/updaters/boost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,7 @@ impl<'a> ReplayProcessor<'a> {
};
let metadata_attributes = &metadata_state.attributes;

let replicated_state_name = self
.cached_object_ids
.replicated_state_name
.and_then(|object_id| metadata_attributes.get(&object_id))
.and_then(|(attribute, _)| match attribute {
boxcars::Attribute::Int(value) => Some(*value),
_ => None,
});
let game_state = self.get_game_state();
let replicated_game_state_time_remaining = self
.cached_object_ids
.replicated_game_state_time_remaining
Expand All @@ -113,8 +106,7 @@ impl<'a> ReplayProcessor<'a> {

let kickoff_countdown_active = replicated_game_state_time_remaining
.is_some_and(|countdown| (1..=3).contains(&countdown))
|| (replicated_state_name
== Some(crate::stats::calculators::GAME_STATE_KICKOFF_COUNTDOWN)
|| (game_state == Some(crate::stats::calculators::GAME_STATE_KICKOFF_COUNTDOWN)
&& replicated_game_state_time_remaining.is_none());

kickoff_countdown_active || ball_has_been_hit == Some(false)
Expand Down
12 changes: 12 additions & 0 deletions src/processor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ pub trait ProcessorView {

fn get_seconds_remaining(&self) -> SubtrActorResult<i32>;
fn get_replicated_state_name(&self) -> SubtrActorResult<i32>;
/// Canonical `GAME_STATE_*` code for the current game state, if any.
///
/// The default passes [`Self::get_replicated_state_name`] through, which is
/// only correct for views that already carry canonical codes. Views backed
/// by replay name-table indices must override it.
fn get_game_state(&self) -> Option<i32> {
self.get_replicated_state_name().ok()
}
fn get_replicated_game_state_time_remaining(&self) -> SubtrActorResult<i32>;
fn get_ball_has_been_hit(&self) -> SubtrActorResult<bool>;
fn get_ignore_ball_syncing(&self) -> SubtrActorResult<bool>;
Expand Down Expand Up @@ -128,6 +136,10 @@ impl ProcessorView for ReplayProcessor<'_> {
ReplayProcessor::get_replicated_state_name(self)
}

fn get_game_state(&self) -> Option<i32> {
ReplayProcessor::get_game_state(self)
}

fn get_replicated_game_state_time_remaining(&self) -> SubtrActorResult<i32> {
ReplayProcessor::get_replicated_game_state_time_remaining(self)
}
Expand Down
14 changes: 12 additions & 2 deletions src/stats/calculators/frame_components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,15 @@ pub struct FrameEventsState {
pub goal_events: Vec<GoalEvent>,
}

pub(crate) const GAME_STATE_KICKOFF_COUNTDOWN: i32 = 53;
pub(crate) const GAME_STATE_GOAL_SCORED_REPLAY: i32 = 67;
pub const GAME_STATE_KICKOFF_COUNTDOWN: i32 = 53;
pub const GAME_STATE_GOAL_SCORED_REPLAY: i32 = 67;

/// Maps a replicated game-state name to its canonical `GAME_STATE_*` code, or
/// `None` for states the stats treat as ordinary play.
pub(crate) fn canonical_game_state(state_name: &str) -> Option<i32> {
match state_name {
"Countdown" => Some(GAME_STATE_KICKOFF_COUNTDOWN),
"PostGoalScored" | "ReplayPlayback" => Some(GAME_STATE_GOAL_SCORED_REPLAY),
_ => None,
}
}
2 changes: 1 addition & 1 deletion src/stats/calculators/frame_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ impl FrameInput {
_ => None,
});
GameplayState {
game_state: processor.get_replicated_state_name().ok(),
game_state: processor.get_game_state(),
ball_has_been_hit: processor.get_ball_has_been_hit().ok(),
kickoff_countdown_time: processor.get_replicated_game_state_time_remaining().ok(),
team_zero_score: team_scores.map(|scores| scores.0),
Expand Down
87 changes: 87 additions & 0 deletions tests/game_state_name_index_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use std::collections::HashMap;

use subtr_actor::*;

fn parse_replay(path: &str) -> boxcars::Replay {
let data = std::fs::read(path).expect("Failed to read replay file");
boxcars::ParserBuilder::new(&data[..])
.must_parse_network_data()
.parse()
.expect("Failed to parse replay")
}

#[allow(clippy::result_large_err)]
fn state_names_by_frame(replay: &boxcars::Replay) -> HashMap<usize, String> {
let mut names = HashMap::new();
let mut collect = |processor: &dyn ProcessorView,
_frame: &boxcars::Frame,
frame_number: usize,
_current_time: f32| {
if let Ok(index) = processor.get_replicated_state_name() {
names.insert(frame_number, replay.names[index as usize].clone());
}
Ok(TimeAdvance::NextFrame)
};
ReplayProcessor::new(replay)
.expect("Failed to create processor")
.process(&mut collect)
.expect("Failed to process replay");
names
}

fn assert_game_states_classify_live_play(path: &str) {
let replay = parse_replay(path);
let state_names = state_names_by_frame(&replay);
let captured = StatsCollector::only_modules(["movement"])
.capture_frames()
.get_captured_data(&replay)
.expect("Failed to collect stats");

let mut active_frames = 0;
let mut live_active_frames = 0;
for frame in &captured.frames {
match state_names.get(&frame.frame_number).map(String::as_str) {
Some("Countdown" | "PostGoalScored" | "ReplayPlayback") => assert!(
!frame.is_live_play,
"{path}: frame {} in state {:?} counted as live play",
frame.frame_number, state_names[&frame.frame_number],
),
Some("Active") => {
active_frames += 1;
live_active_frames += usize::from(frame.is_live_play);
}
_ => {}
}
}

let live_fraction = live_active_frames as f32 / active_frames as f32;
assert!(
live_fraction > 0.8,
"{path}: only {live_fraction:.2} of Active frames were live play"
);
}

// Name-table index 67 is "Active" here, which the old hardcoded goal-replay
// code matched, collapsing live play to about one second.
#[test]
fn game_states_resolve_when_active_collides_with_goal_replay_index() {
let path = "assets/ranked-standard-state-name-index-collision-2024-08-25.replay";
assert_eq!(parse_replay(path).names[67], "Active");
assert_game_states_classify_live_play(path);
}

// Countdown=52, Active=53, PostGoalScored=67, ReplayPlayback=69.
#[test]
fn game_states_resolve_when_active_collides_with_countdown_index() {
assert_game_states_classify_live_play(
"assets/replay-format-2026-06-02-v868-32-net11-worldcup-ball.replay",
);
}

// Countdown=1, Active=2, PostGoalScored=3.
#[test]
fn game_states_resolve_in_small_name_table() {
assert_game_states_classify_live_play(
"assets/replay-format-2016-07-21-v868-12-net-none-lan.replay",
);
}
Loading