Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-demo/tests/demo_frame.rs

//! **facett-demo — the frame-budget / lag-fix proof** (headless, no window).
//!
//! The user reported `facett-demo` *lags and stutters* on a discrete GPU + 12 cores
//! while the sibling `sigtuna_demo` was buttery smooth. Root cause: the eframe binary
//! called [`DemoState::dump_state`] **every native frame** — it deep-serialises the
//! WHOLE ~27-facet deck's [`state_json`](DemoState::state_json) and `fs::write`s it to
//! disk each frame. That tree is not static: the live sim accrues an Iceberg snapshot
//! (and grows the warehouse table) on every tick, so the per-frame serialise+write
//! cost GROWS without bound — a discrete GPU idles while the CPU frame-serialises and
//! fsyncs. `main.rs` now only pays that when an external driver actually asked for the
//! `$FACETT_DEMO_STATE` channel (a robot-UI seam), so a human interactive run does
//! ZERO per-frame serialise + disk write.
//!
//! This test measures the removed cost as DATA (the before → after ms/frame the report
//! quotes), proves it GROWS with sim ticks (the stutter mechanism), and checks the new
//! [`FrameClock`] readout — all RED-when-broken if the meter or the growth regress.

use facett_demo::{DemoState, FrameClock};
use std::time::Instant;

/// Advance the sim `n` ticks and re-feed the live facets (the same wiring
/// `pump_sim` drives, minus the frame throttle) — grows the warehouse table + the
/// accruing Iceberg snapshot list, so the aggregated `state_json` tree grows too.
fn ticked_demo(n: usize) -> DemoState {
    let mut demo = DemoState::new();
    for _ in 0..n {
        demo.sim.tick();
        demo.feed_facets_from_sim();
    }
    demo
}

/// The size (bytes) of the aggregated `state_json` a single `dump_state` serialises —
/// the per-frame payload the old hot path built AND wrote to disk every frame.
fn state_bytes(demo: &DemoState) -> usize {
    serde_json::to_string(&demo.state_json()).map(|s| s.len()).unwrap_or(0)
}

#[test]
fn per_frame_state_dump_is_a_real_growing_cost_now_gated_out() {
    // The payload grows as the live sim accrues snapshots — so the per-frame dump got
    // steadily heavier the longer the demo ran (the "starts okay, then stutters" shape).
    let light = state_bytes(&ticked_demo(0));
    let heavy = state_bytes(&ticked_demo(40));
    eprintln!("[demo-frame] state_json bytes: tick0={light} tick40={heavy}");
    assert!(light > 2_000, "the whole-deck state_json is a non-trivial per-frame payload ({light} B)");
    assert!(heavy > light, "the per-frame dump payload GROWS as the sim accrues snapshots ({light} → {heavy} B)");

    // Time the work the OLD hot path did every frame (build the full tree + serialise)
    // on a heavily-run demo — the ms/frame the fix removes from a plain interactive run.
    let demo = ticked_demo(60);
    let iters = 300;
    // warm-up
    for _ in 0..20 {
        std::hint::black_box(state_bytes(&demo));
    }
    let t = Instant::now();
    for _ in 0..iters {
        std::hint::black_box(serde_json::to_vec(&demo.state_json()).unwrap());
    }
    let per_frame_ms = t.elapsed().as_secs_f64() * 1000.0 / iters as f64;
    eprintln!(
        "[demo-frame] removed per-frame dump cost ≈ {per_frame_ms:.3} ms/frame (build+serialise the whole {}-byte deck tree; the OLD binary ALSO fs::write'd this to disk EVERY frame)",
        state_bytes(&demo)
    );
    // It is real, measurable per-frame work (not free) — which is exactly why paying it
    // unconditionally every frame stuttered, and why gating it out of the interactive
    // loop is the fix. (A generous floor so the assertion is stable across machines.)
    assert!(per_frame_ms > 0.005, "the per-frame dump is real work ({per_frame_ms:.3} ms) — gating it out is the win");

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-demo",
        "demo_frame_dump_gated",
        heavy > light && per_frame_ms > 0.005,
        &format!("state_bytes_tick0={light} tick40={heavy} removed_ms_per_frame={per_frame_ms:.3}"),
    );
}

#[test]
fn frame_clock_meter_tracks_ms_and_fps() {
    // The on-screen / state_json frame-budget readout: a steady 60 fps (16.6 ms) reads
    // back as ~60 fps; a heavy 100 ms frame reads back as ~10 fps. RED if the EWMA or
    // the reciprocal is wrong (a broken meter would hide a regression from the user).
    let mut fc = FrameClock::default();
    for _ in 0..120 {
        fc.tick(1.0 / 60.0);
    }
    assert!((fc.ms - 16.6).abs() < 1.0, "steady 60 fps reads ~16.6 ms/frame (got {:.2})", fc.ms);
    assert!((fc.fps() - 60.0).abs() < 4.0, "…and ~60 fps (got {:.1})", fc.fps());

    let mut slow = FrameClock::default();
    for _ in 0..200 {
        slow.tick(0.1); // 100 ms frames → a hard 10 fps stutter
    }
    assert!(slow.fps() < 15.0, "a 100 ms/frame stutter reads as a low fps ({:.1})", slow.fps());
}