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.
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.
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_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).
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).
Retune the GPU clear colour on both render paths (Metal on iOS, GL on
desktop) from the old dark navy (0.05,0.06,0.10) to a candy-lavender tone
sampled from the regenerated background's mid-gradient. The dark navy was
the one off-palette constant left after P12.1/P12.2; the background art
covers the full drawable in steady state, so it only ever surfaced as a
pre-load flash / potential dark seam. A single shared CLEAR_R/G/B keeps the
two paths from diverging.
Refresh the stale full-board goldens (p4_board, p9_polish), which still
showed the pre-P12.2 dark HUD, to the current cohesive candy palette.
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).
Drive event-appropriate cues from the existing System Sound Services bank,
purely additively — no board/score/move state is read or written and every
call stays inline-if-OS==.ios guarded.
board_view.sx (move-commit path): a committed gesture now plays the swap
slide cue for any swipe intent (legal or the reverted ping-back); a legal
move adds the match pop on its first clearing round; a multi-round chain
adds the escalating cascade cue keyed to the recorded AnimMove depth
(mv.rounds.len), kept distinct from the match pop so a single clear is never
doubled. An illegal swap plays only the swap cue.
main.sx (frame loop): the win/lose stinger fires EXACTLY ONCE, edge-triggered
on the frame the banner comes up — the level has settled won/lost and any
in-flight cascade has finished animating. Status is read-only from the model;
a restart re-arms the edge for a fresh win/lose.
audio.sx: each play_* method logs a per-cue NSLog line at play time so the
ordering is observable via `log show`; cascade_cue_name maps the clamped
combo index to a stable literal (literals only — the string→NSString bridge
needs NUL-terminated bytes).
Refactor GameAudio from the single clear cue into a full bank: load swap,
match, combo1..combo5, win, lose once at startup, each into its own
SystemSoundID via load_system_sound. Expose per-event play methods
(play_swap/play_match/play_cascade/play_win/play_lose), each a single
AudioServicesPlaySystemSound. play_cascade selects the ascending clip by
clamping cascade depth through the pure, OS-agnostic cascade_cue_index
(depth<=1 -> combo1, depth>=5 -> combo5) so P10.4 can snapshot it headlessly.
Layer stays purely additive and inline-if-OS==.ios guarded; the clear trigger
is migrated to sfx_cascade(mv.rounds.len) at its board_view call site, with
sfx_swap/match/win/lose shims staged for P10.3 wiring. No framework added
beyond the already-linked AudioToolbox/CoreFoundation (build.sx untouched).
Feasibility spike outcome: iOS audio from sx is feasible with no sx-library
change. System Sound Services is plain C, reached with the same `#foreign`
FFI uikit.sx already uses (UIApplicationMain / dlsym / CACurrentMediaTime);
AudioToolbox + CoreFoundation are linked per-target in build.sx.
Smallest viable SFX: one short CC0 clip (Kenney Interface Sounds, CC0 1.0)
played when a swap clears a match. Purely additive — audio.sx reads/writes
no score/board/move state; the wiring in board_view only adds a call.
- audio.sx: load clear.wav once, AudioServicesPlaySystemSound on clear
- board_view.sx: trigger sfx_clear() on a legal swap that clears (>=1 round)
- main.sx: allocate + init g_audio at boot
- build.sx: link AudioToolbox + CoreFoundation on iOS
- assets/audio/clear.wav (+ one-line CC0 credit in LICENSE.txt)
Verified: ios-sim build links; 18/18 tests pass; sim boot log shows
"[sx] audio: clear cue loaded" (AudioServicesCreateSystemSoundID succeeded,
asset shipped in the bundle and decoded).
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 purely-visual, transient juice layer over a committed move — score
popups + a tinted particle/flash burst at the clears — with no change to the
model, score, moves, or settled board.
- assets/fx/particle.png: key the painted transparency checkerboard out of the
provided particle art to real alpha (8-connected border flood fill +
smooth luminance falloff that preserves the soft glow), downscaled to a
256x256 RGBA white sparkle. tools/key_particle.sx is the reproducible tool.
- board_fx.sx: BoardFx (live particle bursts + one "+points" popup) and
BoardFxAssets. The engine image path samples texture*white (no draw-time
tint), so the white sprite is tinted per gem colour at LOAD time into one
texture per colour; a burst animates by scale (grow -> shrink) and the soft
texture edges carry the fade. Combos (cascade depth > 1) burst bigger and the
popup is larger + gold. All driven by delta_time and self-pruning.
- board_anim.sx: AnimMove carries the model's cascade.awarded so the popup
shows the real payout without re-deriving any scoring in the view.
- board_view.sx / main.sx: wire BoardFx + the tinted assets, tick each frame,
spawn on a legal commit, and render bursts (clipped to the grid) under the
popups (drawn on top). Input-lock (BoardAnim.active) is untouched; FX never
gate input and may outlast the move slightly before vanishing.
Goldens (iPhone-17-class sim, iOS 26): p6_fx.png (combo: gold "+480" + bursts
mid-cascade), p6_fx_match.png (single match: "+30" + red burst), p6_fx_after.png
(settled board, FX fully gone). Gate: ios-sim build links, 15/15 logic tests
green (scoring/cascade goldens unchanged).
Add a purely-visual animation timeline so the board no longer snaps on a
move. board_anim.sx records, on a value-copy of the pre-move board, the
swap and each cascade round's matched cells + per-column fall provenance,
then BoardView plays it over delta_time: the two swapped gems SLIDE between
cells (and ping out-and-back on an illegal swap), matched gems SCALE OUT,
and survivors FALL into place while refills drop in from above the grid.
The model stays authoritative: plan_and_commit still calls commit_swap on
the real board exactly as before, and the recording replays the identical
primitives from the identical cells + RNG state, so the timeline ends ON
the model's settled board. tests/anim_plan.sx is the determinism guard —
it asserts the committed board, score, moves, and the timeline's final
state all equal an independent commit_swap of the same move, that the
rounds are contiguous, and that an illegal swap records nothing and leaves
the board untouched. All pre-existing logic/cascade goldens stay green.
Evidence (sx-test-metal, iOS 26.0, time-sampled with temporarily-lengthened
durations; committed durations are the short production values):
goldens/p6_anim_swap.png gems sliding between (5,4)/(6,4)
goldens/p6_anim_clear.png matched reds scaling out in row 4
goldens/p6_anim_fall.png gems mid-fall with gaps + refill dropping in
goldens/p6_anim_after.png settled board == model (SCORE 30, MOVES 29/30)
Wire touch input into the model in BoardView.handle_event. A press records
the drag start (new DragInput, heap-allocated so it survives the per-frame
BoardView rebuild between mouse_down and mouse_up); the release resolves the
gesture against the same layout it was drawn with. A swipe — start→end mapped
by swipe_intent to an adjacent-swap intent — is fed straight into
commit_swap: a legal swap applies, cascades (clear→collapse→refill), accrues
score and spends a move; an illegal one reverts, no move. A sub-threshold /
off-board drag carries no intent and falls back to the tap behaviour
(toggle/clear selection). The next frame re-renders board + HUD from the model.
Reuses swipe.sx + board_layout.sx + commit_swap unchanged — this is wiring,
not new legality/cascade logic.
tests/swipe_commit.sx (new golden) drives the full path on the seeded board
(SEED 1337): a rightward swipe (0,0)->(1,0) is illegal (two reds) and reverts
byte-for-byte with no score/move; (5,4)->(6,4) is legal, completes R,R,R on
row 4, awards 30, spends one move.
Sim evidence (iPhone 17, iOS 26.0): goldens/p5_swap_before.png (SCORE 0,
MOVES 30/30) and goldens/p5_swap_after.png (SCORE 30, MOVES 29/30) bracket a
real idb-injected swipe at (276,475)->(327,475) pt = cell (5,4)->(6,4); the
three reds clear and the board matches the model's resolved state.
Tap a gem to select it: BoardView hit-tests the touch to a grid cell and
draws a bright rim + translucent fill over it; tapping the same cell clears
the selection, tapping another moves it, tapping off-board clears it.
Selection only — no swap (that's P5). The HUD renders the live score and
remaining moves (out of the move limit) in the Lato font on a translucent
card above the grid.
The touch→cell geometry is factored into a pure BoardLayout (no GL/stb
imports) that BoardView composes and P5 will reuse for swap endpoints.
tests/hit_test.sx locks point_to_cell as the exact inverse of cell_frame
(every cell center round-trips; off-board taps reject) — headless because
BoardLayout pulls no C imports. goldens/p4_hud.png captures the scene after
a real idb tap at (201,437)pt: the HUD plus a yellow selection rim on the
red gem at cell (col 4, row 3).
Adopt the modules/ui UIPipeline framework (as the chess reference app does)
and replace the P0 placeholder quad with a BoardView (View protocol, modeled
on chess/board_view.sx):
- background.png fills the screen; an 8x8 cell.png grid is centered in the
safe area; each cell's gem is sampled from gems.png by UV column = gem index
(0=red .. 5=purple).
- Drive it from board.sx seeded with 1337 (the board_init golden's seed), so
the on-screen layout matches that snapshot gem-for-gem.
main.sx now hosts the view via UIPipeline (Metal on iOS, GL on desktop) and
heap-allocates the board/asset state behind pointers (UFCS method calls on a
value-typed global mutate a copy, so mutable state must live behind a pointer
as the reference app does).
Vendor the C deps the UI module's image/font path needs (stb_image,
stb_truetype, kb_text_shape, file_utils); their #include "vendors/..." paths
resolve relative to the project root.
Evidence: ios-sim build links clean; tools/run_tests.sh 11/11 pass; running
app captured at goldens/p4_board.png.
Prove the input path end-to-end. Poll platform events in the frame loop
and, on a mouse_down (UIKit touchesBegan, mapped to an Event in uikit.sx),
toggle the centered quad between orange and green. The toggle marks the
vertex buffer dirty; the next frame re-uploads the active color palette
before drawing. Flip on the press only — a tap also delivers mouse_up
(touchesEnded), so toggling on both would net to no visible change.
goldens/p0_input_before.png (orange quad) and goldens/p0_input_after.png
(green quad) bracket a real tap injected at the device-screen center
(201,437 pt) via `idb ui tap` on the booted iOS 26.0 simulator.
Prove the render path end-to-end: bring up UIKit + Metal (already
scaffolded), clear to a solid blue, and draw one centered orange quad via
the GPU protocol's clear+quad path — an MSL pass-through pipeline plus a
6-vertex (2-triangle) NDC buffer, created lazily once the MTLDevice exists.
Geometry is NDC [-0.5, 0.5]² so the quad is the central 50%x50% of the
drawable regardless of device resolution, keeping the screenshot golden
unambiguous. Background (0.10, 0.20, 0.55), quad (1.0, 0.6, 0.0).
goldens/p0_quad.png is the first screenshot golden, captured from the
booted iOS 26.0 simulator after install + launch.
Stand up build.sx (macos + ios/ios-sim targets, bundle id
co.swipelab.m3te, output sx-out/ios/M3te.app, assets dir) and a minimal
main.sx that brings up the platform (UIKit+Metal on iOS, SDL3+GL on
macOS) and renders a solid-clear frame. Add assets/ and goldens/
directories and a .gitignore for build artifacts.
Modeled on game/build.sx and game/main.sx; modules resolve from the
compiler binary with no -L flag.