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
23 changes: 14 additions & 9 deletions crates/athena-sim/src/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
//! read it (athena#120). An unpaused clock advances each tick by the
//! tick length: a `Pause Match Time` stops `Total Time Elapsed` and
//! the match countdown alike. `Set Slow Motion` is recorded only at
//! the factors P8 measured (`MEASURED_SLOW_MOTION`): there `Total Time
//! Elapsed` and a `Wait(.., Ignore Condition)` read as at 100%. Any
//! other factor stops `not_modeled` at the next tick or `Wait`.
//! the range P8 and P9 measured (`MEASURED_SLOW_MOTION`): there `Total
//! Time Elapsed` and a `Wait(.., Ignore Condition)` read as at 100%.
//! Any other factor stops `not_modeled` at the next tick or `Wait`.
//! `Match Time` before any `Set Match Time` is
//! unanswerable — the lobby's phase limit is no input the scenario
//! format carries — and a countdown reaching zero is the phase
//! boundary the simulator does not run. The countdown and pause
//! are modeled, not measured (`docs/findings.md`).

use std::ops::RangeInclusive;

use workshop_rs::Value;

use crate::eval::{eval, number};
Expand All @@ -20,9 +22,12 @@ use crate::step::World;
use crate::unsupported;
use crate::{Change, Finite, Stop, Val};

/// The `Set Slow Motion` percents P8 measured: the 100 control and the
/// 50 and 90 factors (`probes/p8-slow-motion-wait/capture.json`).
const MEASURED_SLOW_MOTION: [f64; 3] = [100.0, 50.0, 90.0];
/// The `Set Slow Motion` percents P8 and P9 measured: 100, 50 and 90
/// (`probes/p8-slow-motion-wait`), then 10, 200, 0 and -50
/// (`probes/p9-slow-motion-extremes`). The script timeline read as at
/// 100% at every one, so the range between the extremes is taken as
/// measured, clamped inputs included (#143).
const MEASURED_SLOW_MOTION: RangeInclusive<f64> = -50.0..=200.0;

/// The match clock: how far the run's game time has come, whether
/// `Pause Match Time` holds it, the countdown `Match Time` reports
Expand All @@ -37,7 +42,7 @@ pub(crate) struct Clock {
/// scenario format carries, so the read has no start value.
pub match_time: Option<Finite>,
/// The last `Set Slow Motion` percent; `100` is the client's
/// normal rate. Recorded only; it scales nothing (P8).
/// normal rate. Recorded only; it scales nothing (P8, P9).
pub slow_motion: Finite,
/// The game-time seconds elapsed; `Total Time Elapsed` reads the
/// start offset plus this.
Expand All @@ -56,8 +61,8 @@ impl Clock {
}
}

/// Stop unless the last `Set Slow Motion` factor is one P8 measured;
/// the simulator's 16 ms tick mapping holds only for those.
/// Stop unless the last `Set Slow Motion` factor is inside the range P8 and P9
/// measured; the simulator's 16 ms tick mapping holds only there.
pub(crate) fn require_measured_slow_motion(&self) -> Result<(), Stop> {
let percent = self.slow_motion.get();
if MEASURED_SLOW_MOTION.contains(&percent) {
Expand Down
6 changes: 6 additions & 0 deletions crates/athena-sim/tests/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ fn p8_slow_motion_wait() {
check_probe_for("p8-slow-motion-wait", 12.0);
}

#[test]
fn p9_slow_motion_extremes() {
// Four factors of about 1.2 s each, after a 3 s start.
check_probe_for("p9-slow-motion-extremes", 12.0);
}

#[test]
fn p7_hero_settings() {
check_probe("p7-hero-settings");
Expand Down
29 changes: 17 additions & 12 deletions crates/athena-sim/tests/match_clock.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! The match clock (athena#120): `Pause`/`Unpause Match Time`,
//! `Set Match Time` and `Set Slow Motion` write it, `Match Time` and
//! `Total Time Elapsed` read it, and every write records a
//! `matchClock` change. `Set Slow Motion` is recorded only at the
//! factors P8 measured (100, 50, 90), where `Total Time Elapsed` and
//! `Wait` rounding read as at 100%; any other factor is `not_modeled`.
//! `matchClock` change. `Set Slow Motion` is recorded only inside
//! the range P8 and P9 measured (-50 to 200), where `Total Time Elapsed`
//! and `Wait` rounding read as at 100%; any other factor is `not_modeled`.

use athena_sim::{Change, Outcome, Val, run_scenario, run_text};

Expand Down Expand Up @@ -200,12 +200,16 @@ fn set_slow_motion_records_the_factor_and_leaves_game_time_unscaled() {
);
}

/// Factors P8 did not measure.
const UNMEASURED: [u32; 2] = [10, 200];
/// Factors just outside the measured range, and far outside it.
const UNMEASURED: [i32; 3] = [201, -51, 1000];

/// Factors P8 and P9 measured: the 100 control, P8's 50 and 90, and
/// P9's 10 and the clamped 200, 0 and -50.
const MEASURED: [i32; 7] = [100, 50, 90, 10, 200, 0, -50];

/// Assert the run stopped `not_modeled` on an unmeasured slow-motion
/// factor, naming it.
fn assert_unmeasured_stop(trace: &athena_sim::Trace, factor: u32) {
fn assert_unmeasured_stop(trace: &athena_sim::Trace, factor: i32) {
let Outcome::NotModeled(not_modeled) = &trace.outcome else {
panic!("{factor}%: expected NotModeled, got {:?}", trace.outcome);
};
Expand All @@ -226,8 +230,9 @@ fn assert_stopped_at_tick(trace: &athena_sim::Trace, tick: u64) {

#[test]
fn waits_under_a_measured_factor_round_up_to_whole_ticks() {
// P8 (measured): at 50% and 90% a `Wait(0.1)` still takes 112 ms.
for factor in [50, 90] {
// P8 and P9 (measured): at every measured factor a `Wait(0.1)`
// still takes 112 ms.
for factor in MEASURED {
let trace = run(
&format!(
" Set Slow Motion({factor});
Expand Down Expand Up @@ -273,7 +278,7 @@ fn a_suspending_wait_until_under_an_unmeasured_factor_is_not_modeled() {

#[test]
fn a_wait_until_timeout_under_a_measured_factor_keeps_the_16_ms_ticks() {
for factor in [50, 90] {
for factor in MEASURED {
let trace = run(
&format!(
" Set Slow Motion({factor});
Expand Down Expand Up @@ -303,16 +308,16 @@ fn an_unmeasured_factor_stops_the_clock_even_with_no_wait() {
fn a_paused_clock_under_an_unmeasured_factor_is_not_modeled() {
let trace = run(
" Pause Match Time;
Set Slow Motion(10);",
Set Slow Motion(201);",
1.0,
);
assert_unmeasured_stop(&trace, 10);
assert_unmeasured_stop(&trace, 201);
}

/// A rule that sets `factor` in the tick after `waiter` suspends at
/// 100%: the resume path must stop at the wake, not run the stale
/// schedule.
fn slow_rule(factor: u32) -> String {
fn slow_rule(factor: i32) -> String {
format!(
r#"
rule ("slow") {{
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,6 @@ The simulator makes these choices only to stay deterministic; each needs a probe
- **Empty world, modeled not measured** — the geometry reads (athena#77, the `athena#75` decision); no capture covers them and the goal excludes real geometry:
- The run has no map geometry, and the trace marks it as `geometry: "emptyWorld"` so the viewer can warn that geometry-dependent results are not real. A raycast never hits and answers by the catalog's documented no-hit return: `Ray Cast Hit Position` the end position, `Ray Cast Hit Player` `Null`, `Ray Cast Hit Normal` the direction from the end back to the start — a zero-length cast is `not_modeled`. `Is In Line of Sight` is true; `Nearest Walkable Position` yields its input; `Is On Ground` is true and `Is In Air`/`Is On Wall`/`Is In Spawn Room` are false — an empty world has none of what they ask about. `Is In Spawn Room` is also a version-2 `state` read: a player that declares `state` answers `state.isInSpawnRoom` instead of the empty world's `false` (athena#89).
- `Teleport` writes the player's scenario `position` to the evaluated destination (`Null` is the origin, the catalog's `zeroAsNull` coercion) and records a `world` change with `field` `position`; `Position Of` reads the new value. `Apply Impulse` changes velocity, which needs physics, and stays `not_modeled`, as does `Is Standing`. `Is Moving` and `Eye Position` are version-2 `state` reads instead (athena#89): a player that declares `state` answers `state.isMoving`/`state.eyePosition`, and a player without `state` reports a `missing_input`.
- **Measured** — `Set Slow Motion` (P8): at factors 50 and 90 `Total Time Elapsed` still advances 16 ms per tick, a `Wait(.., Ignore Condition)` rounds up to whole 16 ms ticks and the Inspector timestamps read as at 100%. Measured at 50% and 90% with the 100% control; the simulator records the write as a `matchClock` change and runs the clock and `Wait`s on unscaled 16 ms ticks at those factors only. Any other factor ends the run `not_modeled` at the next tick, `Wait` or `Wait Until` that would use it. Not measured: the wall-clock ratio and the cause of the unchanged readings (the game was seen to slow, by eye, with no external timer).
- **Measured** — `Set Slow Motion` (P8, P9): at factors 10, 50, 90, 100, 200, 0 and -50 `Total Time Elapsed` still advances 16 ms per tick, a `Wait(.., Ignore Condition)` rounds up to whole 16 ms ticks (16, 100 and 1000 ms take 16, 112 and 1008 ms in P9) and the Inspector timestamps read as at 100%. The factors 200, 0 and -50 lie outside the 0 to 100 percent the wiki documents and read as at 100% too. The simulator records the write as a `matchClock` change and runs the clock and `Wait`s on unscaled 16 ms ticks for every factor from -50 to 200, the measured range. A factor outside that range ends the run `not_modeled` at the next tick, `Wait` or `Wait Until` that would use it. Not measured: factors outside the range, the wall-clock ratio and the cause of the unchanged readings (the game was seen to slow, by eye, with no external timer).
- **Modeled, not measured** — the rest of the match clock (athena#120); no capture covers it:
- `Pause`/`Unpause Match Time` and `Set Match Time` write the run's clock: an unpaused clock advances each tick by 16 ms, on `Total Time Elapsed` and the `Match Time` countdown alike; `Pause` holds it. `Match Time` has no start value — the lobby's phase limit is not an input the scenario format carries — so a read before a `Set Match Time` is `not_modeled`, and a countdown reaching zero ends the run, the phase boundary it crosses being unmodeled. Every write records a `matchClock` change.
Loading
Loading