Files
m3te/tests/gem_pose.sx
swipelab 6f7d2f4db2 lang migration: rename signed integer types sN -> iN
Mechanical sweep of all .sx sources, plan docs, and tests/expected
snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: tools/run_tests.sh 23/23.

Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors
from the sx opt-in UFCS change (sx a47ea14) — independent of this
rename (present pre-sweep); migrated in the follow-up commit.
2026-06-12 09:36:51 +03:00

148 lines
6.4 KiB
Plaintext

// Per-gem animation determinism guard (P6.3): prove the idle/select/land/clear
// poses are PURELY VISUAL pure functions of the animation clock, and — most
// importantly — that at clock t==0 EVERY gem's idle pose is EXACTLY the resting
// sprite. That t==0-rest invariant is what lets the pre-P6.3 goldens (p4_board,
// p4_hud, …) reproduce under the deterministic capture (M3TE_ANIM_TIME=0): if the
// idle ramp were dropped, gems would already be deformed at t=0 and every prior
// golden would churn. It also pins the reactions to rest at their window ends and
// bounds the idle amplitude so the polish stays tasteful.
// No rendering — pure math over gem_anim.sx. Failure is a non-zero exit code.
#import "modules/std.sx";
#import "modules/math";
#import "board.sx";
#import "gem_anim.sx";
// Local f32 abs with explicit 0.0 literals: the stdlib generic `abs` mis-types
// its untyped `0` literals under f32 and returns 0.0 for every f32 input. The
// shipped game never calls abs; only this test needs it, so it rolls its own.
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> i32 {
fails : i64 = 0;
// 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant).
print("== idle t=0 is rest for all cells ==\n");
rest_ok := true;
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
p := idle_pose(0.0, col, row);
if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) {
rest_ok = false;
}
}
}
print("idle_t0_rest {}\n", rest_ok);
if !rest_ok { fails += 1; }
// 2. Past the ramp the idle actually moves, but stays tasteful (bounded).
print("== idle mid-phase deforms, bounded ==\n");
moved := false;
bounded := true;
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
p := idle_pose(0.6, col, row);
if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; }
if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; }
if fabs(p.dy) > 0.05 { bounded = false; }
}
}
print("idle_mid_moves {} idle_bounded {}\n", moved, bounded);
if !moved { fails += 1; }
if !bounded { fails += 1; }
// 3. Select pop: rest at both window ends, a pop in the middle.
print("== select pop envelope ==\n");
s_start := approx(select_pop_scale(0.0), 1.0);
s_end := approx(select_pop_scale(SELECT_DUR), 1.0);
s_mid := select_pop_scale(SELECT_DUR * 0.5) > 1.05;
print("select_start_rest {} select_end_rest {} select_mid_pops {}\n", s_start, s_end, s_mid);
if !s_start { fails += 1; }
if !s_end { fails += 1; }
if !s_mid { fails += 1; }
// 4. Land squash: rest at both window ends, a wobble just after impact.
print("== land squash envelope ==\n");
l_start := approx(land_squash(0.0), 0.0);
l_end := approx(land_squash(LAND_DUR), 0.0);
l_mid := fabs(land_squash(LAND_DUR * 0.12)) > 0.01;
print("land_start_rest {} land_end_rest {} land_mid_wobbles {}\n", l_start, l_end, l_mid);
if !l_start { fails += 1; }
if !l_end { fails += 1; }
if !l_mid { fails += 1; }
// 5. Clear pop: full at t=0, gone at t=1, a tiny anticipation dip BELOW rest in
// the gather window, an overshoot above 1 mid-window, then — past the peak —
// a strictly monotonic collapse to nothing (a single clean pop, no second
// reversal). The locked t=0/t=1 endpoints keep the seam to the model board.
print("== clear pop envelope ==\n");
c_start := approx(clear_pop_scale(0.0), 1.0);
c_end := approx(clear_pop_scale(1.0), 0.0);
c_dip := clear_pop_scale(CLEAR_DIP_T * 0.5) < 1.0;
c_peak := clear_pop_scale(0.30) > 1.1;
c_collapse := true;
pc := clear_pop_scale(CLEAR_POP_RISE);
for 1..21 (i) {
tt := CLEAR_POP_RISE + (1.0 - CLEAR_POP_RISE) * cast(f32) i / 20.0;
vv := clear_pop_scale(tt);
if vv > pc + 0.000001 { c_collapse = false; }
pc = vv;
}
print("clear_start_full {} clear_end_gone {} clear_dips {} clear_overshoots {} clear_collapses {}\n",
c_start, c_end, c_dip, c_peak, c_collapse);
if !c_start { fails += 1; }
if !c_end { fails += 1; }
if !c_dip { fails += 1; }
if !c_peak { fails += 1; }
if !c_collapse { fails += 1; }
// 6. GemMotion land bookkeeping: fresh state is unpinned at t=0, a never-
// landed cell rests, and a freshly-stamped land reads age 0.
print("== gem motion land bookkeeping ==\n");
m : GemMotion = ---;
m.init();
init_ok := m.clock == 0.0 and !m.pinned;
no_land := land_squash(m.land_local(0)) == 0.0;
m.clock = 2.0;
m.stamp_land(10);
fresh_land := approx(m.land_local(10), 0.0);
print("motion_init {} motion_no_land {} motion_fresh_land {}\n", init_ok, no_land, fresh_land);
if !init_ok { fails += 1; }
if !no_land { fails += 1; }
if !fresh_land { fails += 1; }
// 7. Restart resets the landing bounce. The restart button (BoardView.
// do_restart) reseeds the model AND must clear the per-gem landing state,
// or a restart fired right after a terminal cascade carries that move's
// squash onto the freshly seeded board. reset_landings is the factored
// reset do_restart calls: a cell stamped a moment ago is mid-squash, and
// after reset_landings every cell is back at rest — while the idle clock
// is deliberately left running (idle resumes from its normal phase).
print("== gem motion restart resets landings ==\n");
r : GemMotion = ---;
r.init();
r.clock = 5.0;
r.stamp_land(3);
r.stamp_land(42);
r.clock = 5.08; // 0.08s past impact: well inside LAND_DUR, so mid-squash.
pre_squashing := fabs(land_squash(r.land_local(3))) > 0.01
and fabs(land_squash(r.land_local(42))) > 0.01;
r.reset_landings();
post_rest := land_squash(r.land_local(3)) == 0.0
and land_squash(r.land_local(42)) == 0.0
and land_squash(r.land_local(0)) == 0.0;
clock_kept := r.clock == 5.08;
print("restart_pre_squashing {} restart_post_rest {} restart_clock_kept {}\n",
pre_squashing, post_rest, clock_kept);
if !pre_squashing { fails += 1; }
if !post_rest { fails += 1; }
if !clock_kept { fails += 1; }
if fails == 0 {
print("ok: per-gem animation rests at t=0 and stays bounded\n");
return 0;
}
print("FAIL: {} gem-anim checks failed\n", fails);
return 1;
}