bevy_fairy 0.12.0

An agentic debugger for Bevy games: a Bevy plugin that exposes the game's state over BRP, and an MCP server that lets a coding agent read and drive it
Documentation
//! `bevy_fairy/time`: pause the game clock and set its speed.
//!
//! Gregory, *Game Engine Architecture* (3rd ed., §10.4–10.6), lists a pause and a time scale among
//! the features that make in-engine debugging useful: an agent can freeze a frame, then inspect it.
//! This drives `Time<Virtual>`, the clock that gameplay and animation read, so pausing it freezes
//! the simulation but not the renderer or BRP itself.
//!
//! The command writes a [`ClockRequest`] and does not fight the host for `Time<Virtual>`. A system in
//! `First`, ahead of bevy_time's own clock system, applies the request every frame, so a host that
//! writes `Time<Virtual>` later in the frame (an `unpause`, a `set_relative_speed`) cannot undo it.
//! A host that must combine the request with its own holds and slow motion turns that system off with
//! `DebuggerPlugin::host_owns_clock` and reads [`ClockRequest`] itself. Either way the reply says
//! whether the request held, measured on the frame after it was made (issue #10).

use bevy::prelude::*;
use bevy::time::TimeSystems;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::plugin::commands::{add_builtin_command, DebugCommandInfo};

/// Params of `bevy_fairy/time`. Both fields are optional; an empty call only reports the clock.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TimeParams {
    pub pause: Option<bool>,
    pub speed: Option<f32>,
}

/// What the agent asked of the game clock. `bevy_fairy/time` writes it; it stays until the agent
/// changes it (a pause survives a new game or a phase change). A host that owns the clock reads it:
/// OR `pause` into its own hold and multiply `speed` into its own factor.
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct ClockRequest {
    /// Hold `Time<Virtual>` paused.
    pub pause: bool,
    /// The speed multiplier asked for; `1.0` is real time.
    pub speed: f32,
}

impl Default for ClockRequest {
    fn default() -> Self {
        Self { pause: false, speed: 1.0 }
    }
}

/// What the clock did on the last frame, and under which request.
#[derive(Clone, Copy)]
struct Observation {
    request: ClockRequest,
    delta: std::time::Duration,
    /// The speed the clock advanced at: `request.speed`, or less when `clamped`.
    effective_speed: f32,
    /// The speed `Time<Virtual>` held when it advanced, whoever set it.
    relative_speed: f32,
    /// Whether `Time<Virtual>` was paused on that frame, whoever paused it.
    paused: bool,
    /// The engine cut the frame to `max_delta`, so `effective_speed` is below `relative_speed`.
    clamped: bool,
}

/// The largest `speed` the command accepts. `Time<Virtual>` multiplies a frame's raw delta by the
/// speed with `Duration::mul_f64`, which panics when the product does not fit a `Duration`
/// (about 1.8e19 s), and the multiplication runs before the `max_delta` clamp: a speed of 1e30
/// crashed the game on the next frame (issue #12). The engine caps each frame at `max_delta`
/// (250 ms by default), so any speed above `max_delta / frame time`, about 15 at 60 fps, gives
/// the same clock; 1000 is far above that and far below the overflow.
const MAX_SPEED: f32 = 1000.0;

/// Private bookkeeping: whether the host owns the clock, what this crate has overridden (so a
/// cleared request is undone exactly once), and the last observation.
#[derive(Resource, Default)]
pub(crate) struct ClockState {
    pub(crate) host_owned: bool,
    paused_by_us: bool,
    speed_by_us: bool,
    observed: Option<Observation>,
}

/// Registers `bevy_fairy/time` as a recorded built-in command.
pub(crate) fn register(app: &mut App, host_owned: bool) {
    app.init_resource::<ClockRequest>()
        .insert_resource(ClockState { host_owned, ..default() })
        .add_systems(First, observe_clock.after(TimeSystems));
    if !host_owned {
        app.add_systems(First, apply_request.before(TimeSystems));
    }
    add_builtin_command::<TimeParams, _>(
        app,
        DebugCommandInfo::new(
            "bevy_fairy/time",
            "Pause or unpause the game clock (Time<Virtual>) and set its speed multiplier. The request persists and is re-applied every frame against a host that writes the clock; it does not fight a host's own pause or slow motion once the request is back to not paused at 1.0. Returns the clock state, `held` (did the clock match the request on the last frame: paused when asked to be, running when not, at the asked speed; a pause that leaves speed at 1.0 is judged on the pause alone; false when something else holds the clock, null with `unavailable` until a frame has run), `clamped_by_max_delta` (true when the engine cut the last frame to the clock's max_delta, 250 ms by default, so `effective_speed` is below `speed` though nothing overrode the request) and `host_owned`.",
        )
        .params("pause: bool, optional; speed: f32 in (0, 1000], optional (1.0 is real time)")
        .example(json!({"pause": true})),
        handle_time,
    );
}

/// Applies the request to the clock, and undoes what this crate set once the request is cleared.
fn apply(request: &ClockRequest, state: &mut ClockState, clock: &mut Time<Virtual>) {
    if request.pause {
        clock.pause();
        state.paused_by_us = true;
    } else if state.paused_by_us {
        clock.unpause();
        state.paused_by_us = false;
    }
    if request.speed != 1.0 {
        clock.set_relative_speed(request.speed);
        state.speed_by_us = true;
    } else if state.speed_by_us {
        clock.set_relative_speed(1.0);
        state.speed_by_us = false;
    }
}

fn apply_request(
    request: Res<ClockRequest>,
    mut state: ResMut<ClockState>,
    mut clock: ResMut<Time<Virtual>>,
) {
    apply(&request, &mut state, &mut clock);
}

fn observe_clock(request: Res<ClockRequest>, clock: Res<Time<Virtual>>, mut state: ResMut<ClockState>) {
    let relative_speed = clock.relative_speed();
    state.observed = Some(Observation {
        request: *request,
        delta: clock.delta(),
        effective_speed: clock.effective_speed(),
        relative_speed,
        paused: clock.is_paused(),
        // The engine cuts a frame to exactly `max_delta` and reports the speed that gives; an
        // unclamped frame advances at the speed it was given.
        clamped: !clock.is_paused() && clock.delta() == clock.max_delta() && clock.effective_speed() < relative_speed,
    });
}

fn handle_time(
    In(params): In<TimeParams>,
    mut request: ResMut<ClockRequest>,
    mut state: ResMut<ClockState>,
    mut clock: ResMut<Time<Virtual>>,
) -> Result<Value, String> {
    if let Some(speed) = params.speed {
        if !speed.is_finite() || speed <= 0.0 || speed > MAX_SPEED {
            return Err(format!(
                "speed must be > 0 and at most {MAX_SPEED}, got {speed}. Time<Virtual> overflows past that, and the engine caps a frame at its max_delta anyway. Example: {{\"speed\": 4.0}}"
            ));
        }
    }
    if let Some(speed) = params.speed {
        request.speed = speed;
    }
    if let Some(pause) = params.pause {
        request.pause = pause;
    }
    // Apply now so the reply reads the new clock; the host-owned mode never writes the clock.
    if !state.host_owned {
        apply(&request, &mut state, &mut clock);
    }

    let mut reply = json!({
        "paused": clock.is_paused(),
        "speed": clock.relative_speed(),
        "effective_speed": clock.effective_speed(),
        "requested": { "pause": request.pause, "speed": request.speed },
        "host_owned": state.host_owned,
    });
    match state.observed.filter(|seen| seen.request == *request) {
        Some(seen) => {
            let near = |speed: f32| (speed - request.speed).abs() <= 1e-3 * request.speed;
            // `held` is whether the clock matched the request on that frame: paused when it was
            // asked to be, running when it was not, at the speed asked for. "Not paused, 1.0" is a
            // request like any other (B-129): it used to be skipped, so a host holding its own pause
            // or running its own slow motion read as `held: true` beside `paused: true`.
            let pause_held = if request.pause { seen.delta.is_zero() } else { !seen.paused };
            // A paused clock has no effective speed to read. A request of 1.0 asks for no speed (the
            // plugin never writes 1.0 over a host's own), so a pause beside a host's slow motion is
            // held; any other speed is compared with the one the clock holds for when it resumes.
            // A frame the engine clamped advanced slower than the speed in force, so the request
            // held when the speed in force is the request's.
            let speed_held = if seen.paused {
                request.speed == 1.0 || near(seen.relative_speed)
            } else {
                near(seen.effective_speed) || (seen.clamped && near(seen.relative_speed))
            };
            reply["held"] = json!(pause_held && speed_held);
            reply["clamped_by_max_delta"] = json!(seen.clamped);
        }
        None => {
            reply["held"] = Value::Null;
            reply["clamped_by_max_delta"] = Value::Null;
            reply["unavailable"] = json!(
                "no frame has run under this request yet; call bevy_fairy/time with no params to read whether it held"
            );
        }
    }
    Ok(reply)
}