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.
render_fall now offsets each COLUMN's drop START by a small bounded delay
(fall_stagger_t) so a refilled/collapsed row pours in as a left-to-right cascade
instead of every gem snapping down in one flat lockstep row. Column col waits
FALL_STAGGER_MAX (0.30) * col/7 of the fall window, then falls over the remaining
1 - 0.30, with that local progress fed through ease_in_cubic so each column still
accelerates under gravity within its own window.
Bounded by construction: the last column lands EXACTLY at t=1 and every earlier
column strictly before it, so no gem is ever left mid-air at the segment end — the
seam to the next round / settled board stays invisible and move.final is untouched.
FALL_ANIM_DUR (0.22s) and the timeline helpers (phase/total/cascade_rounds_started)
are unchanged, so the per-round cascade-cue timing snapshots don't churn and live
per-round audio is unaffected. Render-only — no board.sx model change.
tests/easing.sx pins fall_stagger_t: f(0)=0, f(1)=1 across all columns (no gem
unlanded), per-column monotonicity, and the mid-fall cascade ordering (each later
column strictly behind the one before). tests/anim_plan.sx (final==model,
contiguity) stays green.
Golden goldens/p17_stagger.png: M3TE_FX=11 (depth-5 cascade, seed 1337) pinned at
M3TE_ANIM_TIME=1.91 — round 4 refills columns 2-7 by one cell each, so the top row
reads as a left-to-right staircase (vs the pre-stagger flat row in p17_fall.png).
render_fall now drives the per-round drop with ease_in_cubic (P15.1 accel-from-
rest) instead of ease_out_cubic, so falling gems start slow and accelerate into
place like gravity rather than decelerating. f(1)=1 is pinned, so every gem still
lands exactly on its destination cell and move.final is untouched. FALL_ANIM_DUR
(0.22s) is unchanged, so the cascade-cue timing snapshots don't churn.
Golden goldens/p17_fall.png: M3TE_FX=11 (depth-5 cascade, seed 1337) pinned at
M3TE_ANIM_TIME=1.51 (round 3 fall window [1.38,1.60)) — gems caught bunched-high
mid-fall, ~20% down at ~59% of the segment, vs the old curve's ~93%.
render_swap's rejected-swap branch now drives the two gems with a P15.1
spring-based bounce-back (bad_swap_bounce): a quick lunge toward the
neighbour, then a damped spring home that overshoots rest by a bounded
amount and settles to exactly 0. f(0)=f(1)=0, so the move stays purely
visual — board byte-identical to pre-swap, no score/move spent.
- board_anim.sx: add bad_swap_bounce envelope (lunge via ease_out_cubic,
settle via 1 - spring(u)); BADSWAP_LUNGE_T/AMP constants.
- board_view.sx: replace the linear ping-out illegal branch with the bounce.
- main.sx: add illegal_swaps (complement of legal_swaps, same row-major
order) + the startup-only M3TE_BADSWAP=n capture hook; mirrors M3TE_FX.
- tests/easing.sx: append bounce-envelope assertions (endpoints, single
lunge peak + location, damped settle); regenerate expected snapshot.
- README.md: document the M3TE_BADSWAP recipe + goldens/p16_badswap.png.
Gate green: ios-sim build links, 22 logic snapshots pass (anim_plan model
invariants unchanged; SWAP_ANIM_DUR untouched so cascade-cue snapshots do
not churn).
Replace the legal swap's flat ease_out_cubic decelerating slide with the
P15.1 ease_out_back overshoot curve: the two swapped gems shoot ~10% PAST
their target cells, then settle exactly onto them. Purely visual — the curve
pins f(0)=0 / f(1)=1, so t==0 is the rest pose and t==1 lands byte-on-cell;
the committed move and final board are unchanged. SWAP_ANIM_DUR (0.16 s) is
untouched, so the cascade-cue timing snapshots do not churn.
Only the legal branch of render_swap changes; the illegal ping-back is left
as-is for P16.2. Model/logic untouched (FFI is the only non-sx surface).
Golden goldens/p16_swap.png: M3TE_FX=3 (top-row swap (5,0)<->(6,0)) pinned at
M3TE_ANIM_TIME=0.10 (swap-phase t~0.625, near the overshoot peak). Measured
against calibrated cell centers: the red lands ~8% left of col-5 and the green
~12% right of col-6 (both PAST target), while every unswapped gem stays
centered; at M3TE_ANIM_TIME=0 the same gems sit dead-on their pre-swap cells.
Gate: `sx build --target ios-sim main.sx` links clean; `tools/run_tests.sh`
22/22 (anim_plan, easing, cascade_rounds, cascade_cue all green).
The README audio-regeneration section called tools/synth_audio.py the
'build-time DSP path', but that script is the original P10.1 procedural
synthesizer: its main() overwrites assets/audio/*.wav with note-frequency
synthetic audio. Following the documented path would clobber the curated
Triple Treat pack cues. Remove the script (cleanest resolution — kills the
clobber hazard) and rewrite the README to describe the actual production:
real pack clips (per-cue source in assets/audio/LICENSE.txt) down-mixed,
trimmed, faded, peak-normalized to ~-15 dBFS, re-wrapped via afconvert;
combo ladder = real Match FX ordered by brightness (P10.9); cascade one cue
per round (P10.10). measure_pitch.py kept (read-only verification, never
writes a WAV). WAVs/.sx/goldens byte-unchanged.
The M3TE_* capture pins are read only at app startup, so a second `simctl
launch` against the still-running app reused its PID and silently ignored the
new pin (the win->lose cue recipe only ever produced `cue win`). Add
`--terminate-running-process` to every pinned launch across all capture recipes
and document the startup-only rule explicitly. Docs-only; no .sx change.
Integration-only (no logic changes). Validated the full candy vibe in the booted
iPhone sim and brought every artifact in line with the shipped candy palette +
Triple Treat SFX bank.
Goldens — swept all 23:
- Refreshed 15 that predated the candy palette (P12): p6_idle_t0, p6_idle_mid,
p6_select, p7_restart, p5_swap_before/after, p6_anim_swap/clear/fall/after,
p6_fx, p6_fx_after, p6_fx_match, p6_inputlock_board, p11_combo_deep — re-captured
via the documented M3TE_* hooks.
- Left 5 unchanged (board+HUD region byte-identical to the current build, verified
by cropped-region hash): p4_board, p4_hud, p9_polish, p7_win, p7_lose.
- Removed 3 obsolete pre-board orange-quad goldens (app no longer renders them):
p0_quad, p0_input_before, p0_input_after.
Docs — README.md:
- Section 2 now describes the candy board (not the old orange quad) and points at
goldens/p6_idle_t0.png; dropped the removed p0_* references.
- Added the final audio model: Triple Treat SFX provenance + per-cue mapping, the
per-round ascending cascade (one combo cue per round, clamped at combo5), the
WAVE/mono/44100/Int16 @ -15 dBFS format spec, and the cue-log capture commands.
- Added image-art asset regeneration (codex imagegen via codex exec + sips
normalize to exact per-asset dims/format).
Gate: ios-sim build links (exit 0); 21/21 pure-sx logic tests pass. Playthrough
evidence (cue NSLog ascending combo1..combo5 + win/lose stingers, screenshots)
captured in the worker report.
Make the selection highlight read as glossy candy without new art and
without disturbing the idle-rest invariant. board_view's render_selection
layers a soft outward glow (two concentric stroked rings — the renderer has
no blur), a warm wash, a bright rim doubled by a thin inner highlight for a
glassy edge, and a wet sheen that rides the selected gem's live pose. Every
layer is a rect/overlay (issue 0002 forbids a draw-time gem-texture tint).
The gloss is selection-only: render_selection runs solely when a cell is
selected, so the resting board (no selection) is byte-identical to before
and the t==0 idle pose stays exactly the static sprite (locked by
tests/gem_pose). The selection-pop motion still comes from gem_anim; no
board / score / move state changes and input stays gated by BoardAnim.active.
Updated goldens/p6_select.png; README documents the P11.3 selection gloss
and its reproduce commands (reusing the P6.3 M3TE_SELECT + M3TE_ANIM_TIME
hooks).
Scale the combo FX with cascade depth (mv.rounds.len) — the same depth the
cascade SFX (play_cascade) steps up on — so deeper cascades read as more
exciting and land in lockstep with the audio escalation. Purely visual and
self-pruning: no board / score / move state changes, and input stays gated by
BoardAnim.active alone.
- board_fx.sx: add fx_combo_level (mirrors audio's cascade_cue_index clamp:
depth<=1 -> floor, depth>=5 -> ceiling). The +points popup now carries the
cascade depth and grows one font step + lerps gold -> hot-gold per level
(fx_popup_font / fx_popup_color). Every burst of a deep cascade gets a
whole-move depth boost (FX_BURST_DEPTH) on top of the existing per-round bump.
- board_view.sx: render_fx_popups derives styling from depth and tops a combo
with a "COMBO xN" label naming the true cascade depth.
- tests/fx_combo.sx: headless snapshot locking the depth->level/font table and
asserting fx_combo_level matches the cascade-cue index column entry-for-entry.
- goldens/p11_combo_deep.png + README: deterministic depth-5 capture (M3TE_FX=11)
vs the depth-1 single clear (M3TE_FX=3); FX gone after settle at a later phase.
Visual-juice vibe-pass, FX-only — no logic/state changes, input gating
still owned by BoardAnim.active.
- board_fx.sx: bigger, punchier match bursts — peak size 1.95->2.50 cells,
combo bonus 0.55->0.72, and the per-gem fx tints saturated a touch (low
channel trimmed, dominant/mid lifted) so every burst pops as a brighter,
more vivid candy colour. The hot per-pixel tint loop's hoisted locals are
preserved (issue 0001).
- gem_anim.sx: snappier clear pop — faster rise (0.30->0.18 of the window)
to a bigger overshoot (CLEAR_POP_A 0.22->0.34) so the matched-gem clear
reads as a candy snap. gem_pose's clear-pop invariants still hold.
- main.sx: M3TE_FX=<n> deterministic match-FX capture hook, mirroring the
M3TE_SELECT pattern. Commits the n-th currently-legal swap at startup via
the normal plan_and_commit path and begins the move timeline + burst/popup
FX; M3TE_ANIM_TIME pins the phase and the frame loop holds the move/FX
frozen while pinned, so the burst + "+points" screenshot identically every
run. A larger M3TE_ANIM_TIME captures the settled, FX-gone board. Startup-
only and guarded, so normal play is untouched.
- README.md: document the new M3TE_FX pin alongside the other capture hooks.
- goldens/p6_fx_match.png: updated deterministic golden (iOS 26 sim,
SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22) — the vertical
red 3-match, burst region +1.4% mean luminance / 3.2:1 brighter:dimmer vs
the same scene on the pre-juice constants.
Gate: ios-sim build links, 19/19 logic tests green (incl. gem_pose t=0 rest).
Extend the HUD to show the per-level goal (SCORE x / target) alongside
moves. When the model's level_status (P7.1) is won/lost, draw a centered
overlay banner ("YOU WIN!" / "OUT OF MOVES") with a "PLAY AGAIN" restart
button over the dimmed board; the banner appears once any winning/losing
cascade animation settles. Status is read from the model, never recomputed
in the view.
A finished level freezes board-cell input; only the restart button is live.
Its rect is derived from the shared BoardLayout grid (new BannerLayout), so
the hit-test lands exactly on the drawn button. A tap reseeds the same
starting level through board.restart and clears the transient view layers,
returning to a clean in_progress board.
Banner is text + rects only (honours colour/alpha; no draw-time image tint,
issue 0002). New env capture hooks (M3TE_TARGET / M3TE_MOVE_LIMIT /
M3TE_RESTART) force a terminal status / restart for deterministic goldens.
Tests: tests/banner_layout.sx locks the restart button rect <-> hit-test
round-trip headlessly. Goldens p7_win / p7_lose / p7_restart captured on the
iOS simulator.
New gem_anim.sx adds a purely-visual per-gem pose set driven by a single
animation clock: a calm always-on idle breath (scale-pulse + bob, per-gem
phase, ramped in from rest), a selection pop, a landing squash-bounce, and
a clear pop. BoardView draws every settled gem through gem_pose_at /
gem_pose_frame; the move timeline (P6.1) and FX (P6.2) are untouched and the
input-lock semantics are unchanged (idle never locks input).
Determinism: the idle is always-on, so main reads M3TE_ANIM_TIME=<seconds>
to freeze the clock at a chosen phase (t==0 == the resting board, so the
pre-P6.3 goldens reproduce) and M3TE_SELECT=<cellIndex> to force a selection
for capture. tests/gem_pose.sx locks the t==0-rest invariant and the reaction
envelopes headlessly (fails if the idle ramp is dropped).
Goldens (deterministic capture): p6_idle_t0 (resting), p6_idle_mid (pinned
mid-breath), p6_select (selection pop on cell 3,3). Purely visual: no change
to model/score/moves/hit-testing.
Add a real logic-test gate so future pure-sx game logic fails the build on a
bad assertion:
- tests/test.sx: `expect(cond, msg)` assert helper — prints a greppable
`FAIL <file>:<line>: <msg>` and exits non-zero via process.exit on failure.
- tools/run_tests.sh: snapshot runner mirroring sx/tests/run_examples.sh; runs
each tests/<name>.sx and diffs stdout + exit code against tests/expected/.
Exits 0 iff all tests pass.
- tests/arith.sx (+ expected snapshots): seed passing sanity test.
- README.md: document both halves of the gate — logic runner and the
reproducible ios-sim build/launch sequence (with device discovery).