P20.1: FPS counter — env-gated dev overlay (M3TE_FPS), off by default

Add a small top-left FPS readout for gauging frame cost while tuning the
organic animations. Gated behind the M3TE_FPS startup env pin (read like the
other M3TE_* hooks); unset/=0 renders nothing, so default play and every
committed golden stay byte-identical.

- main.sx: g_fps_on (from M3TE_FPS) + g_fps_avg_dt, an EMA of delta_time
  (FPS_DT_SMOOTH=0.9) advanced only on the gated path; build_ui passes the
  smoothed FPS + flag into BoardView. delta_time is real wall-clock even when
  M3TE_ANIM_TIME pins the scene, so the counter stays live while frozen.
- board_view.sx: BoardView.fps_on/fps fields + render_fps_overlay — "FPS n"
  in the top-left safe-area corner (clear of notch/Dynamic Island + the HUD),
  dark grape text over a bright halo. Drawn last, only when fps_on.
- README.md: document M3TE_FPS (sim SIMCTL_CHILD_ + device devicectl env).
- goldens/p20_fps.png: FPS overlay over the resting board (M3TE_FPS=1,
  M3TE_ANIM_TIME=0); FPS digits are dynamic, rest pinned == p6_idle_t0 region.

Verified: ios-sim build + 22 logic tests green. Unset capture's board+HUD
region is byte-identical to goldens/p6_idle_t0.png; the only ON-vs-OFF delta
is the top-left FPS text box.
This commit is contained in:
swipelab
2026-06-06 11:46:51 +03:00
parent d0c90a6833
commit ff88e4ab87
4 changed files with 93 additions and 2 deletions

28
main.sx
View File

@@ -45,6 +45,15 @@ g_viewport_w : f32 = 800.0;
g_viewport_h : f32 = 600.0;
g_safe_insets : EdgeInsets = .{};
// FPS dev overlay (P20.1). OFF unless the M3TE_FPS env pin is set, so default
// play and every committed golden stay byte-identical. `g_fps_avg_dt` is an
// exponential moving average of the per-frame delta, smoothed so the readout
// doesn't jitter wildly; the displayed FPS is its reciprocal. Both are only
// touched on the gated path, so the unset path is unchanged.
FPS_DT_SMOOTH :f32: 0.9; // weight on the running average vs. this frame's delta
g_fps_on : bool = false;
g_fps_avg_dt : f32 = 0.016;
// iOS-only concrete handles kept alongside the boxed `g_plat` so the frame loop
// can reach the CAMetalLayer pointer / pixel dims without going through the
// protocol box.
@@ -96,7 +105,8 @@ g_banner_prev_up : bool = false;
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View {
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED }
fps : f32 = if g_fps_avg_dt > 0.0 then 1.0 / g_fps_avg_dt else 0.0;
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED, fps_on = g_fps_on, fps = fps }
}
// Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot
@@ -200,6 +210,14 @@ frame :: () {
g_viewport_h = fc.viewport_h;
g_safe_insets = g_plat.safe_insets();
// FPS dev overlay (P20.1): advance the smoothed frame-time average ONLY when
// the env pin enabled it, so an unset run never touches this and renders
// byte-identically. delta_time is real wall-clock even when M3TE_ANIM_TIME
// pins the animation clock, so the readout is live while the scene is frozen.
if g_fps_on and g_delta_time > 0.0 {
g_fps_avg_dt = g_fps_avg_dt * FPS_DT_SMOOTH + g_delta_time * (1.0 - FPS_DT_SMOOTH);
}
if fc.viewport_w != g_pipeline.screen_width or fc.viewport_h != g_pipeline.screen_height {
g_pipeline.resize(fc.viewport_w, fc.viewport_h);
}
@@ -380,6 +398,14 @@ main :: () -> void {
}
}
// FPS dev-overlay hook (P20.1): a non-zero M3TE_FPS turns on the corner FPS
// readout. Default (unset / =0) leaves it off, so normal play and every
// committed golden stay byte-identical. Purely a render overlay — no board /
// score / move / animation state changes and it never gates input.
if fp := read_env("M3TE_FPS") {
if parse_s64(fp) != 0 { g_fps_on = true; }
}
// Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move,
// which the sim can't script (no public touch injection), so M3TE_FX forces a
// representative match at startup the same way a swipe would: it commits the