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
55 changes: 35 additions & 20 deletions crates/athena-sim/src/clock.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
//! The match clock: `Pause`/`Unpause Match Time`, `Set Match Time` and
//! `Set Slow Motion` write it, `Match Time` and `Total Time Elapsed`
//! read it (athena#120). An unpaused clock advances each tick by the
//! tick length scaled by the slow-motion percent: a `Pause Match Time`
//! stops `Total Time Elapsed` and the match countdown alike, and
//! `Set Slow Motion` scales how fast both run. A `Wait` maps seconds
//! to ticks the clock does not describe, so a `Wait` under a non-100
//! factor — and a `Wait Until` whose timeout would suspend — stops
//! `not_modeled` rather than guess the mapping. `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. Modeled,
//! not measured (`docs/findings.md`).
//! 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`.
//! `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 workshop_rs::Value;

Expand All @@ -20,9 +20,13 @@ 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 match clock: how far the run's game time has come, whether
/// `Pause Match Time` holds it, the countdown `Match Time` reports
/// and the `Set Slow Motion` percent that scales its rate.
/// and the last `Set Slow Motion` percent.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Clock {
/// `Pause Match Time` holds the clock; it advances only while
Expand All @@ -32,8 +36,8 @@ pub(crate) struct Clock {
/// Time` writes it — the lobby's phase limit is not an input the
/// scenario format carries, so the read has no start value.
pub match_time: Option<Finite>,
/// The `Set Slow Motion` percent; `100` is the client's normal
/// rate.
/// The last `Set Slow Motion` percent; `100` is the client's
/// normal rate. Recorded only; it scales nothing (P8).
pub slow_motion: Finite,
/// The game-time seconds elapsed; `Total Time Elapsed` reads the
/// start offset plus this.
Expand All @@ -52,19 +56,30 @@ 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.
pub(crate) fn require_measured_slow_motion(&self) -> Result<(), Stop> {
let percent = self.slow_motion.get();
if MEASURED_SLOW_MOTION.contains(&percent) {
return Ok(());
}
unsupported(format!("Set Slow Motion at {percent}%"))
}

/// Advance the clock into a tick: an unpaused clock gains
/// `tick_s` scaled by `slow_motion` on `elapsed` and loses the
/// same on `match_time`. A paused clock stands. A countdown that
/// reaches zero crosses the phase boundary the simulator does not
/// run — game-mode advancement is not modeled — so the run stops.
/// `tick_s` on `elapsed` and loses the same on `match_time`. A
/// paused clock stands. A countdown that reaches zero crosses the
/// phase boundary the simulator does not run — game-mode
/// advancement is not modeled — so the run stops. An unmeasured
/// slow-motion factor stops the run first.
pub(crate) fn advance(&mut self, tick_s: f64) -> Result<(), Stop> {
self.require_measured_slow_motion()?;
if self.paused {
return Ok(());
}
let step = tick_s * self.slow_motion.get() / 100.0;
self.elapsed = Finite::produced(self.elapsed.get() + step, "match clock")?;
self.elapsed = Finite::produced(self.elapsed.get() + tick_s, "match clock")?;
if let Some(remaining) = self.match_time {
let remaining = remaining.get() - step;
let remaining = remaining.get() - tick_s;
if remaining <= 0.0 {
return unsupported("matchTime reaching zero");
}
Expand Down
69 changes: 3 additions & 66 deletions crates/athena-sim/src/sched.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,57 +293,6 @@ pub(crate) fn record_stop(
stop.stopped_at(location)
}

/// A suspended `Wait`/`Wait Until` whose wake fires under a non-100
/// `Set Slow Motion` factor would resume on the schedule the suspend
/// computed at 100% — the same unmeasured seconds-to-ticks mapping the
/// `wait`/`waitUntil` arms stop on, so the wake stops the run naming
/// the construct. `live` marks a `Wait Until` wake its own condition
/// re-check set this tick, which encodes no schedule.
fn stale_wake(
instance: &Instance<'_>,
rule_list: &[&Rule],
world: &mut World,
live: bool,
tick: u64,
) -> Result<(), Stop> {
if live || world.clock.slow_motion.get() == 100.0 {
return Ok(());
}
// A `Wait Until` frame names its action; a `Wait`'s suspending
// frame sits one past it.
let (rule, at, construct) = match instance.wait_mode {
WaitMode::Until(frame) => (
frame.rule_idx,
Some(StopIndex::Action(frame.pc)),
"waitUntil",
),
_ => match instance.frames.last() {
Some(frame) => (
frame.rule_idx,
Some(StopIndex::Action(frame.pc - 1)),
"wait",
),
None => (instance.rule_idx, None, "wait"),
},
};
Err(record_stop(
&mut world.runs,
RuleRun {
rule,
name: rule_list[rule].name.clone(),
event_player: event_player(instance),
ended: RunEnd::Stopped,
},
at,
NotModeled::new(format!(
"action {construct} under a {}% simulation rate",
world.clock.slow_motion.get()
))
.at(tick)
.into(),
))
}

fn tick_loop(
mut loaded: Loaded<'_>,
world: &mut World,
Expand All @@ -358,8 +307,8 @@ fn tick_loop(
let mut changes_recorded = 0;
for tick in 0..ticks {
// The match clock advances into each tick after the first:
// paused it stands, slowed it scales, and a countdown reaching
// zero ends the run before the tick's rules do any work.
// paused it stands, and a countdown reaching zero ends the run
// before the tick's rules do any work.
if tick > 0 {
world.clock.advance(TICK_S).map_err(|stop| stop.at(tick))?;
}
Expand Down Expand Up @@ -498,7 +447,6 @@ fn run_tick(
let edge = holds && !instance.prev;
instance.prev = holds;
// A wake due this tick wins over the wait behavior.
let mut until_live = false;
if instance.wake.is_some_and(|wake| wake > tick) {
match instance.wait_mode {
WaitMode::Ignore => {}
Expand Down Expand Up @@ -554,16 +502,12 @@ fn run_tick(
)
})? {
instance.wake = Some(tick);
until_live = true;
}
}
}
}
let start = match instance.wake {
Some(wake) if wake <= tick => {
stale_wake(instance, rule_list, world, until_live, tick)?;
true
}
Some(wake) if wake <= tick => true,
Some(_) => false,
None => {
if edge && instance.frames.is_empty() {
Expand All @@ -587,10 +531,6 @@ fn run_tick(
}
Event::Player { .. } => {
if instance.wake.is_some_and(|wake| wake <= tick) {
// Event rules suspend only on `Wait(.., Ignore
// Condition)`, so a wake here is always the stale
// schedule `stale_wake` gates.
stale_wake(instance, rule_list, world, false, tick)?;
instance.wake = None;
run_and_record(instance, rule_list, blocks, subroutines, world, tick, now)
.map_err(|e| e.at(tick))?;
Expand All @@ -608,7 +548,6 @@ fn run_tick(
// pushed onto it. A subroutine rule has no conditions, so
// the wait behaviors that read them do not apply; a
// `Wait Until` still re-checks its own condition.
let mut until_live = false;
if instance.wake.is_some_and(|wake| wake > tick)
&& let WaitMode::Until(frame) = instance.wait_mode
{
Expand Down Expand Up @@ -646,11 +585,9 @@ fn run_tick(
)
})? {
instance.wake = Some(tick);
until_live = true;
}
}
if instance.wake.is_some_and(|wake| wake <= tick) {
stale_wake(instance, rule_list, world, until_live, tick)?;
instance.wake = None;
}
if instance.wake.is_none() && !instance.frames.is_empty() {
Expand Down
19 changes: 2 additions & 17 deletions crates/athena-sim/src/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,16 +593,8 @@ pub(crate) fn run_instance(
if mode != WaitMode::Ignore && inst.event.is_some() {
return unsupported("wait behavior in an event-triggered rule");
}
world.clock.require_measured_slow_motion()?;
let ticks = wait_ticks(number(&eval(seconds, &env)?)?);
// The catalog's seconds-to-ticks mapping under a
// non-100 `Set Slow Motion` factor is unmeasured —
// stop rather than guess it (athena#120).
if world.clock.slow_motion.get() != 100.0 {
return unsupported(format!(
"action wait under a {}% simulation rate",
world.clock.slow_motion.get()
));
}
inst.wake = Some(tick + ticks);
inst.wait_mode = mode;
advance(inst);
Expand Down Expand Up @@ -633,14 +625,7 @@ pub(crate) fn run_instance(
if timeout <= 0.0 {
return unsupported("Wait Until(false, 0)");
}
// The same unmeasured seconds-to-ticks mapping as
// `wait` gates a suspending `Wait Until`.
if world.clock.slow_motion.get() != 100.0 {
return unsupported(format!(
"action waitUntil under a {}% simulation rate",
world.clock.slow_motion.get()
));
}
world.clock.require_measured_slow_motion()?;
inst.wake = Some(tick + wait_ticks(timeout));
inst.wait_mode = WaitMode::Until(Frame { rule_idx, pc });
advance(inst);
Expand Down
2 changes: 1 addition & 1 deletion crates/athena-sim/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pub struct TimelineEntry {
pub tick: u64,
/// The tick's `Total Time Elapsed` in milliseconds: 15 + the
/// elapsed game-time seconds. The match clock drives it, so a
/// paused or slowed clock repeats values across ticks.
/// paused clock repeats values across ticks.
pub time: Finite,
pub rules: Vec<RuleRun>,
pub changes: Vec<Change>,
Expand Down
15 changes: 12 additions & 3 deletions crates/athena-sim/tests/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,12 @@ fn compare_logs(trace: &Trace, captured: &Capture) -> Result<(), String> {
const RUN_SECONDS: f64 = 10.0;

fn check_probe(id: &str) {
let options = Options {
seconds: RUN_SECONDS,
};
check_probe_for(id, RUN_SECONDS);
}

/// `check_probe` for a probe whose run outlasts `RUN_SECONDS`.
fn check_probe_for(id: &str, seconds: f64) {
let options = Options { seconds };
// A probe that needs a real player carries a `scenario.json`; without
// one `run_text` runs the bare program.
let scenario = probe_file(id, "scenario.json")
Expand Down Expand Up @@ -243,6 +246,12 @@ fn p6_wait_behavior() {
check_probe("p6-wait-behavior");
}

#[test]
fn p8_slow_motion_wait() {
// Three factors of about 2.7 s each, after a 3 s start.
check_probe_for("p8-slow-motion-wait", 12.0);
}

#[test]
fn p7_hero_settings() {
check_probe("p7-hero-settings");
Expand Down
Loading
Loading