The local vendors/ copies existed because the old modules/ffi/stb*.sx
resolved C paths CWD-relative, forcing every consumer to carry
identically-named copies. sx now ships these as proper library vendors
(#import "vendors/<name>/<name>.sx"), so the copies and the retired
ffi module imports both go. Verified: sx build --target ios-sim
bundles M3te.app; tools/run_tests.sh 23/23.
sx 1d17b0a reserves 'cstring' as the C-boundary string type and renames
std's cstring(size) allocator to alloc_string; std getenv is now
(cstring) -> ?cstring, so the local conflicting binding (caught by the
new same-symbol diagnostic) and its strlen/copy loop collapse into a
process.env delegation. iOS-sim build + 22/22 snapshots green.
Free function restart(board, seed) is dot-called from main.sx and
board_view.sx; the sx opt-in UFCS change gates plain functions out of
dot-dispatch, so declare it ufcs. ios-sim build green, 23/23 logic
tests.
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.
Drop the ':' before captures (for xs (x) / for 0..n (i)); the index
capture becomes the trailing open range (for xs, 0.. (x, i)). 136
headers across 26 files, mechanical.
Five headless tests (banner_layout, hit_test, swipe_commit,
swipe_intent, swipe_reshuffle) also gain a direct
#import "modules/ui/types.sx" — they named Point/Frame through a
transitive import, which bare visibility no longer permits.
Gates: sx build --target ios-sim main.sx links; tools/run_tests.sh
23/23.
The rendered swipe-commit path (`plan_and_commit`) bypassed the turn-loop's
no-moves rule: a deadlocked board (no legal swap) stayed stuck on screen because
only `play_turn` checked `!has_legal_swap` and reshuffled, and the UI never calls
`play_turn`.
Factor the post-settle "no legal swaps -> reshuffle" check into a shared
`reshuffle_if_deadlocked` in board.sx and call it from BOTH `play_turn` and
`plan_and_commit`, so the animated UI commit obeys the identical model rule. The
reshuffle runs after the cascade settles (post-`commit_swap`); the AnimMove's
recorded `final` stays the settled pre-reshuffle board, so the cascade animation,
per-round audio, and input gating are unchanged — the reshuffled layout renders on
the next settled frame. No win/lose/turn-accounting change; a reshuffle spends no
move and no score.
Regression test tests/swipe_reshuffle.sx drives the exact UI path (swipe_intent ->
plan_and_commit) on the deadlocked board from tests/level.sx: before = no legal
swaps / in_progress; after = reshuffled (has_legal_swap true, 9 legal swaps, no
immediate match), score/moves/budget unchanged. It FAILS pre-fix (board stays
stuck, has_legal_swap false) and PASSES post-fix.
Screenshotting immediately after 'simctl launch' grabs the blank white
launch screen, not the rendered board. Add an explicit 'sleep 2' between
launch and screenshot in the main capture recipe, and state a once-stated
render-readiness rule (near the first recipe) that every pinned-launch
section below references, so a clean-checkout reader reproduces the board.
Docs-only: no .sx/asset/golden change.
Final golden sweep + doc finalization for the organic-animation pass. No .sx
logic/render change; goldens + README only (CLAUDE.md is local-only/gitignored).
Refreshed 8 stale goldens to the SHIPPED render (each verified deterministic):
- p6_anim_swap/clear/fall, p6_fx, p6_inputlock_board (M3TE_FX=3 at swap/clear/fall
segment phases): last captured at P13.1, so they still showed the PRE-organic
flat tweens; now show the merged overshoot / pop-ripple / gravity-accel motion.
- p17_fall, p17_stagger, p17_land (M3TE_FX=11 deep cascade): captured incrementally
at P17.1/.2/.3 and never updated as later steps changed the shared cascade frame
(P17.2 stagger, P17.3 squash, P18.2 burst ripple), so the committed PNGs no longer
matched the shipped code (12-20% board-region diff). Re-captured to the final,
fully-merged fall motion at their documented phases.
README:
- Added the previously-undocumented move-timeline frame recipes (swap/clear/fall/
after segment phases + the resting-board goldens), filling the doc gap.
- Fixed the now-false P17 prose: p17_fall was described as a "pre-stagger lockstep
reference" and p17_stagger as carrying "no squash" — both untrue once the full
fall motion shipped. Now describes all three as the final combined motion, each
pinned to foreground a different tell (accel / staircase / squash wave).
Verified all 28 goldens: 8 refreshed, 20 unchanged (board+HUD region byte-identical
over a status-bar/home-indicator crop), 0 removed. t=0 rest, win/lose, HUD, select,
idle, FPS, p16/p18 and p11_combo_deep all reproduce. Gate green (ios-sim build +
22/22 logic tests; tests/gem_pose.sx + tests/easing.sx pin the t=0 rest invariant).
Refresh the P18.1 README prose for goldens/p18_pop.png (M3TE_FX=3 at
M3TE_ANIM_TIME=0.21): the refreshed mid-clear golden now shows the P18.2
ripple, not all three matched gems together at their fullest overshoot.
Correct the bash comment and the p6_fx_match.png note that implied a
uniform pop. Docs-only; no .sx, WAV, or golden changes.
Within a clearing round the matched gems no longer all explode at once: each
gem's pop (and its burst) START is offset by a bounded per-gem delay so the
cells detonate as a ripple.
- board_anim.sx: clear_ripple_t(t,u) mirrors fall_stagger_t's (t-delay)/window,
delaying a gem's pop START by CLEAR_STAGGER_MAX*u (0.45 of the clear window).
Bounded: every gem still reaches local 1 (scale 0) by t==1, so none is left
mid-pop at the seam to the fall. clear_diag_span/clear_rank rank each matched
gem 0..1 by diagonal (col+row) PER ROUND, so even a 3-match ripples across the
full budget.
- board_view.sx render_clear: feed each matched gem's ranked, staggered local t
through the P18.1 clear_pop_scale (locked endpoints unchanged).
- board_fx.sx: bursts carry the same per-gem delay so they ripple in lockstep
with the pops. Per-round audio cue (P10.10) still fires once at t0, not per gem.
- Model untouched (same cells cleared, same final board); CLEAR_ANIM_DUR fixed,
so cascade-cue snapshots don't churn and M3TE_ANIM_TIME=0 still rests.
- tests/easing.sx: pin clear_ripple_t endpoints, bounded completion by t==1,
monotonicity, ripple ordering, and the diagonal rank.
- goldens: add p18_stagger (M3TE_FX=3 @ 0.22); refresh p18_pop, p6_fx_match,
p11_combo_deep (all pinned mid-clear, now showing the ripple).
Sharpen clear_pop_scale from a plain pop-then-shrink into a candy pop in
three beats over its local 0..1: a tiny anticipation squash dip (~8% below
rest), a snappy overshoot to ~1.40x via P15.1's ease_out_back, then an
accelerating collapse to nothing (ease_in_quad). Endpoints stay locked
(t=0 -> 1.0 rest, t=1 -> 0.0 gone), so the seam to the model board is clean
and M3TE_ANIM_TIME=0 still reproduces the rest board; the particle burst /
score popup compose on top unchanged. render_clear and CLEAR_ANIM_DUR are
untouched, so cascade-cue timing snapshots don't churn and the model is
unchanged.
tests/gem_pose.sx now pins the new envelope (locked rest endpoints, the
anticipation dip, the overshoot, the monotonic post-peak collapse) with its
expected snapshot updated.
goldens/p18_pop.png: new mid-clear pop golden at the overshoot peak
(M3TE_FX=3 M3TE_ANIM_TIME=0.21). goldens/p6_fx_match.png refreshed for the
new pop shape (same scene at its documented 0.22). README documents P18.1.
Give each landing gem a wide-and-short squash-&-settle bounce as it touches
its destination, applied WITHIN the fall so EVERY cascade round bounces
(staggered per column), not only the final whole-move settle.
One envelope, one bounce: land_squash is now LAND_SQUASH_A * squash_envelope
(P15.1) over its normalized window, so the per-round fall bounce and the
settle bounce are the exact same shape. render_fall/render_clear age a
per-column bounce from each column's touch-down instant (fall_landing_frac *
FALL_ANIM_DUR) via the shared rest_squash + delivering_round helpers, so a gem
still in the air draws unsquashed and only a landed gem flattens; the squash
carries across the fall->clear seam.
Double-bounce reconciliation (approach a): drive the bounce from the per-round
fall and DROP the old whole-move "stamp at age 0" settle. The settle stamp is
now BACK-DATED per column (clock - (total - round_land_time)) so render_gems
resumes land_squash exactly where render_fall left off at the render_anim ->
render_gems seam — one continuous bounce, no double-pop.
Amplitude tuned 0.13 -> 0.18 (~13% peak) so the bounce reads while staying
tasteful; durations unchanged, so the cascade-cue snapshots don't churn.
M3TE_ANIM_TIME=0 still reproduces goldens/p6_idle_t0.png (a resting board
carries no landing stamp). New goldens/p17_land.png pins a staggered landing
mid-pour (M3TE_FX=11 ANIM_TIME=1.94). tests/easing.sx gains a landing-instant
section pinning fall_landing_frac / round_land_time; tests/gem_pose.sx stays
green (land_squash values are identical).
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).
Pure, headless easing curves of t in [0,1] for the organic-animation pass
(swap/fall/combine juice), placed alongside the existing ease_out_cubic /
ease_in_quad in board_anim.sx: ease_in_cubic, ease_in_out_cubic, ease_out_back
(bounded overshoot, settles to exactly 1), spring (damped wobble to exactly 1),
and squash_envelope (signed squash-&-stretch landing shape). The math module has
no exp/pow, so the decaying curves use a (1-t)^n polynomial envelope that hits 0
at t==1, pinning f(1) precisely.
Additive only: no render code calls the new curves yet. tests/easing.sx locks,
per curve, the endpoints, overshoot/undershoot bounds, and monotonicity-where-
required (booleans only, so the snapshot is platform-stable), structured so P16.2
can append illegal-swap bounce-back assertions. Test count 21 -> 22.
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.
For combos, play a sound for each match ascending (Candy-Crush cascade run):
as a chain resolves, EACH successive round plays the next higher cue
(combo1, combo2, … clamped at combo5) instead of a single combo cue keyed
to the final cascade depth at commit.
- board_anim.sx: add `BoardAnim.cascade_fired` (edge-trigger high-water mark,
reset on init/begin) and the pure `cascade_rounds_started(elapsed, n)` helper
— how many rounds have begun clearing on the swap→(clear,fall)* timeline.
- main.sx: in the frame loop, diff `cascade_rounds_started` against
`cascade_fired` and play one ascending cue per newly-cleared round, once each,
gated on a real multi-round chain (rounds >= 2). Additive; never touches
board/score/move state.
- board_view.sx: drop the single `sfx_cascade(final depth)` at commit; keep
`sfx_swap` / `sfx_match` (and win/lose) exactly as before.
- tests/cascade_rounds.sx: headless snapshot of the per-round timing + the
ascending combo1..combo5 run with the combo5 clamp.
Sim (M3TE_FX=11, depth-5): log show shows combo1→combo2→combo3→combo4→combo5
at successive timestamps ~0.36s apart (= CLEAR+FALL per-round spacing).
Replace the pitch-laddered single-pop combo ladder with FIVE distinct REAL
Match FX cues from the user's Triple Treat SFX pack (Match SFX/), ordered to
convey cascade escalation by spectral brightness / high-frequency energy
(combo1 dullest -> combo5 sparkliest):
combo1 <- Match SFX/Match FX 2-RCM.wav (dark/full, centroid ~1.7 kHz)
combo2 <- Match SFX/Match FX 4-RCM.wav (warm mid, centroid ~2.1 kHz)
combo3 <- Match SFX/Match FX 6-RCM.wav (bright mid, centroid ~3.2 kHz)
combo4 <- Match SFX/Match FX 7-RCM.wav (rich+bright, centroid ~4.7 kHz)
combo5 <- Match SFX/Match FX 3-RCM.wav (sparkly, centroid ~6.8 kHz)
The Match FX set does not cleanly pitch-ascend, so brightness is the ordering
signal; spectral centroid ascends monotonically 1.68 < 2.09 < 3.18 < 4.70 <
6.77 kHz. Each down-mixed to mono, trimmed to a 0.50 s onset window, eased in
(~6 ms) and rounded out with a 150 ms cosine fade-out, peak-normalized -15 dBFS.
Pure drop-in: audio.sx and all wiring are untouched; swap/match/win/lose are
byte-identical to P10.8. The depth->cue-index mapping (cascade_cue, fx_combo)
locks the integer mapping, not the audio content, so both tests stay valid.
LICENSE.txt combo provenance updated to the real Match FX sources.
Replace the 9-cue bank with best-fit selections from the user-provided
Triple_Treat_SFX.zip, converted to the engine format (mono / 44100 / Int16,
<= ~600 ms) and peak-normalized to a gentle, consistent -15 dBFS. Drop-in:
audio.sx and all wiring are untouched; only assets/audio/** changes.
Per-cue source within the pack:
swap <- Transition SFX/Swipe FX 1 (light swipe = the swap gesture)
match <- Pop:Bubble SFX/Pop FX 5 (juicy candy pop, first clear)
combo1..5 <- Pop:Bubble SFX/Pop FX 3 (one pop pitch-laddered +0/+2/+4/+7/+9
semitones; the pack's Match set does not ascend monotonically)
win <- Success:Power-Up SFX/Power Up FX 1 (short triumphant)
lose <- Fail SFX/Fail FX 2 (gentle tonal, not boomy)
combo1..5 ascend in fundamental: 687 < 771 < 865 < 1029 < 1155 Hz. The 30 MB
pack and its .meta/__MACOSX cruft are not committed; LICENSE.txt records the
exact per-cue source file within the pack.
DSP-soften the existing P10.6 CC0 bank in place — same filenames, same
canonical WAVE/mono/44100/Int16 format, drop-in (engine untouched):
- Onset eased in: short qsin fade-in (8-14 ms) tames the attack transient
so pops bloop instead of snap (onset peak in first 20 ms down 5.6-8.7 dB
per cue; combo attack-to-peak 1.4-2.4 ms -> 15-26 ms).
- Highs rolled off: warm two-pole low-pass (2.6 kHz swap/lose, 3.0 kHz
match/combo, 3.6 kHz win) for a rounded tone. Spectral centroid down
~40-60%; >4 kHz energy collapses (win 76%->28%, combos ~10%->0.5%).
- Quieter: re-normalized to -15.5 dBFS (swap/lose -17.5), down from ~-9/-12,
lowering both peak and RMS on every cue.
Candy character retained; cascade ladder preserved (combo1..5 fundamentals
still ascend 1045<1173<1317<1566<1758 Hz). LICENSE provenance updated.