Files
m3te/README.md
swipelab 2f5d60b9e1 P13.1: final vibe integration — playthrough validation, full golden sweep, finalized docs
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.
2026-06-06 08:57:26 +03:00

323 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# m3te
A match-3 game written entirely in the **sx** language, targeting **iOS** first.
- Game logic, rendering, input, and UI are all authored in sx.
- Art (palettes, sprite sheets) is produced as real assets.
- Verification gate: sx logic tests pass AND the iOS app builds & launches in the Simulator.
Development is driven by the multi-agent `flow` (Product Owner → Worker → Reviewer → Observer).
## Verification gate
The gate has two halves. Both must pass. The sx compiler used below lives at
`/Users/agra/projects/sx/zig-out/bin/sx` (override the runner's binary with the
`SX` env var). Run everything from the repo root.
### 1. Logic tests
Pure-sx logic tests run under `sx` and have their stdout + exit code diffed
against committed snapshots in `tests/expected/`. A failed assertion exits the
process non-zero, so it fails the runner (and the gate).
```bash
bash tools/run_tests.sh
```
- A test is any `tests/<name>.sx` that has a `tests/expected/<name>.exit`
marker; `tests/test.sx` (the `expect` assert helper) has no marker, so it is
not itself run.
- Regenerate snapshots after an intentional change: `bash tools/run_tests.sh --update`.
### 2. iOS Simulator build + launch
Build the app for the simulator, then install/launch it on an available device
and screenshot the rendered scene — the brighter **candy** match-3 board: a soft
pink→lavender→blue gradient background, an 8×8 grid of glossy candy gems on cream
cell tiles, and a grape candy HUD card (score / moves) above the grid.
```bash
# Build the .app bundle (sx-out/ios/M3te.app):
/Users/agra/projects/sx/zig-out/bin/sx build --target ios-sim main.sx
# Discover an available simulator — do NOT hardcode a udid:
xcrun simctl list devices available
# e.g. capture the first available device's UDID into $udid:
udid=$(xcrun simctl list devices available | grep -Eo '[0-9A-Fa-f-]{36}' | head -1)
# Boot it (skip if already "Booted") and bring the Simulator window up:
xcrun simctl boot "$udid" || true
open -a Simulator
# Install, launch (bundle id co.swipelab.m3te), and screenshot:
xcrun simctl install booted sx-out/ios/M3te.app
xcrun simctl launch booted co.swipelab.m3te
xcrun simctl io booted screenshot /tmp/m3te.png
```
The screenshot should match `goldens/p6_idle_t0.png` (the resting candy board),
modulo the status-bar clock — pixel-exact equality is not required; compare the
board + HUD region, not the top status strip. A tap selects a cell; a swipe
between two adjacent gems commits a swap (legal swaps cascade + score, illegal
ones ping back). The deterministic capture hooks below pin a chosen scene so each
golden reproduces from a clean checkout.
> The earlier `p0_*` goldens (a pre-board orange quad over a blue clear, from the
> P0 foundation slice) were removed in P13.1: the app no longer renders that
> scene, so they could only ever mislead.
### Deterministic animation capture (P6.3)
The per-gem idle loop (`gem_anim.sx`) is always-on, so a plain screenshot is
time-dependent. Two environment variables pin the visual state so the board can
be captured reproducibly. The simulator forwards any `SIMCTL_CHILD_*` variable to
the launched app, so prefix them on the `simctl launch`:
- `M3TE_ANIM_TIME=<seconds>` freezes the animation clock at that phase. **`t=0`
is the resting board** — every gem sits at its static pose, so the pre-P6.3
goldens reproduce unchanged. A larger `t` (e.g. `1.0`) shows the mid-breath
idle deformation. The select/land reactions read this same pinned phase.
- `M3TE_SELECT=<cellIndex 0..63>` (= `row*8 + col`) force-selects a cell at
startup, so the selection highlight + pop can be captured without a tap.
```bash
# Resting board (idle at rest): goldens/p6_idle_t0.png
SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch booted co.swipelab.m3te
# Mid-breath idle: goldens/p6_idle_mid.png
SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch booted co.swipelab.m3te
# Selection pop on cell (3,3): goldens/p6_select.png
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch booted co.swipelab.m3te
```
With no variable set the game runs fully live (the clock advances by
`delta_time`). `tests/gem_pose.sx` locks the `t==0`-rest invariant headlessly.
### Level-state capture (P7.2)
The win/lose banner and restart button are driven by the model's `level_status`
(score vs. goal vs. move budget). Three more env hooks force a terminal status
(or a restart) so the banner / restart states can be screenshot deterministically
without scripting a winning swipe — combine them with `M3TE_ANIM_TIME` to pin the
idle clock:
- `M3TE_TARGET=<n>` overrides the per-level score goal. `0` makes the fresh board
read **won** immediately (`score 0 ≥ goal 0`).
- `M3TE_MOVE_LIMIT=<n>` overrides the move budget. `0` makes it read **lost**
(budget spent below the goal).
- `M3TE_RESTART=<non-zero>` runs `board.restart` after the overrides, capturing
the fresh `in_progress` board the restart button produces.
```bash
# Win banner + restart over the board: goldens/p7_win.png
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch booted co.swipelab.m3te
# Lose banner ("OUT OF MOVES") + restart: goldens/p7_lose.png
env SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch booted co.swipelab.m3te
# Fresh in_progress board after restart: goldens/p7_restart.png
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_RESTART=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch booted co.swipelab.m3te
```
While a banner is up the board freezes (only the restart button is live, per
P7.1's finished-level rule); `tests/banner_layout.sx` locks the restart button's
rect ↔ hit-test round-trip headlessly.
### Match-FX capture (P11.1)
The match bursts + score popup (`board_fx.sx`) only spawn off a *committed* move,
which the simulator can't script (there is no public touch injection). One more
env hook forces a representative match at startup so the FX can be screenshot
deterministically — combine it with `M3TE_ANIM_TIME` to freeze the phase:
- `M3TE_FX=<n>` commits the **n-th currently-legal swap** (1-based, clamped; `=1`
is the first) through the normal `plan_and_commit` path, then begins the move
timeline + its burst/popup FX. While `M3TE_ANIM_TIME` is set the move/FX
timelines are pinned at that phase (the frame loop holds them frozen), so the
burst and floating `+points` render identically every run. A larger
`M3TE_ANIM_TIME` lands past the timeline, capturing the settled board with the
FX fully pruned. Startup-only and guarded by the var, so normal play is
untouched.
The legal-swap order is the fixed enumeration in `tests/expected/swap_legality.stdout`
(row-major, right-before-down). For seed 1337, `M3TE_FX=3` is the vertical red
3-match used by the golden, and `M3TE_FX=11` is a **depth-5 cascade** (the deepest
on this seed) used to capture the escalated combo emphasis (next section).
```bash
# Punchy match burst + "+30" popup, pinned mid-clear: goldens/p6_fx_match.png
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch booted co.swipelab.m3te
# Same match, later phase — FX fully gone over the settled board (no golden):
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
xcrun simctl launch booted co.swipelab.m3te
```
### Escalating combo emphasis (P11.2)
The combo FX escalates with cascade depth (`mv.rounds.len`), the SAME depth the
cascade SFX (`play_cascade`) steps up on: a deeper cascade gets a bigger, hotter-
gold `+points` popup topped by a `COMBO xN` label, and bursts that grow from the
first round. The depth→emphasis clamp (`fx_combo_level`) mirrors the cascade cue's
`cascade_cue_index` exactly (depth ≤ 1 → floor, depth ≥ 5 → ceiling); the
equivalence is locked headlessly by `tests/fx_combo.sx`.
Capture it with the same `M3TE_FX` hook — `M3TE_FX=11` is a depth-5 cascade on
seed 1337, contrasted against the depth-1 single clear `M3TE_FX=3`:
```bash
# Escalated COMBO x5 + gold "+1050" + bigger burst: goldens/p11_combo_deep.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch booted co.swipelab.m3te
# Single clear for contrast — plain white "+30", no COMBO label (goldens/p6_fx_match.png):
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
xcrun simctl launch booted co.swipelab.m3te
# Deep cascade at a later phase — all combo FX gone over the settled board (no golden):
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch booted co.swipelab.m3te
```
The combo emphasis is purely visual and self-pruning: it never gates input
(`BoardAnim.active` owns gating) and never touches board / score / move state.
### Glossier gem & selection feel (P11.3)
The selection highlight (`board_view.sx` `render_selection`) is a candy-glossier
overlay: two concentric stroked rings fake a soft outward glow, a warm wash tints
the cell, a bright rim is doubled by a thin inner highlight for a glassy edge, and
a wet sheen rides the selected gem's live pose. The engine can't tint a texture at
draw time (issue 0002), so every layer is a rect/overlay — never a gem-texture
tint. The selection-pop motion still comes from `gem_anim`, so the **t==0 idle
pose is byte-identical to the static sprite** (locked by `tests/gem_pose.sx`); the
gloss is selection-only, so the resting board (no selection) is unchanged.
Capture it with the same P6.3 hooks — no new env var:
```bash
# Glossy candy selection on cell (3,3), pinned mid-pop: goldens/p6_select.png
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch booted co.swipelab.m3te
# Same selection at exact rest (no pop) — isolates the overlay:
env SIMCTL_CHILD_M3TE_ANIM_TIME=0 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch booted co.swipelab.m3te
```
The selection gloss is purely visual: it never gates input (`BoardAnim.active`
owns gating) and never touches board / score / move state.
## Audio bank (P10) — final model
The SFX bank (`audio.sx`) is a purely additive layer over iOS **System Sound
Services** (AudioToolbox, reached by `#foreign` FFI exactly as the platform reaches
UIKit/Metal). Each cue is loaded once at startup into its own `SystemSoundID`;
playback is a single C call. The bank only plays when *every* cue loaded (`loaded`
gate), so a partial/failed bank mutes rather than playing a stale id. It never
reads or mutates score / board / move state — `board_view` and the frame loop just
tell it an event happened.
### Provenance — the user-provided "Triple Treat SFX" pack
The nine shipped cues are best-fit selections from the user-supplied **Triple Treat
SFX** pack (`Triple_Treat_SFX.zip`, ~30 MB, 280 Unity-style files). The pack itself
is **not committed** — only the selected, converted cues live in `assets/audio/`;
the source archive is kept outside the repo (in `~/Downloads`). Full per-cue notes
are in `assets/audio/LICENSE.txt`. The shipped game never re-synthesizes anything;
it only loads the finished WAVs.
| cue | game event | pack source (under `Triple Treat SFX/`) |
|--------------|------------------------------------|------------------------------------------|
| `swap.wav` | every committed swipe (subtle) | `Transition SFX/Swipe FX 1-RCM.wav` |
| `match.wav` | first clear of a legal move (pop) | `Pop:Bubble SFX/Pop FX 5-RCM.wav` |
| `combo1.wav` | cascade round 1 (dullest) | `Match SFX/Match FX 2-RCM.wav` (~1.7 kHz)|
| `combo2.wav` | cascade round 2 | `Match SFX/Match FX 4-RCM.wav` (~2.1 kHz)|
| `combo3.wav` | cascade round 3 | `Match SFX/Match FX 6-RCM.wav` (~3.2 kHz)|
| `combo4.wav` | cascade round 4 | `Match SFX/Match FX 7-RCM.wav` (~4.7 kHz)|
| `combo5.wav` | cascade round ≥5 (brightest) | `Match SFX/Match FX 3-RCM.wav` (~6.8 kHz)|
| `win.wav` | level won (stinger) | `Success:Power-Up SFX/Power Up FX 1-RCM.wav` |
| `lose.wav` | level lost (stinger) | `Fail SFX/Fail FX 2-RCM.wav` |
The Match-FX set does not cleanly pitch-ascend, so `combo1..5` are ordered by
ascending **spectral brightness** (centroid 1.68 < 2.09 < 3.18 < 4.70 < 6.77 kHz)
so a deeper cascade reads as more exciting.
### Per-round ascending cascade
The cascade plays **one ascending cue per cascade round** — round 1 → `combo1`,
round 2 → `combo2`, … clamped at `combo5`*not* a single end-of-move sound. Each
cue fires on the move's animation timeline, edge-triggered as that round's clear
begins: the frame loop (`main.sx`) diffs `cascade_rounds_started(elapsed, rounds)`
against `BoardAnim.cascade_fired` and plays the next combo cue per newly-cleared
round (`sfx_cascade`). The depth→index clamp (`cascade_cue_index`: depth ≤ 1 → 0,
depth ≥ 5 → 4) is pure + headless, snapshot-tested by `tests/cascade_cue.sx` and
`tests/cascade_rounds.sx`. A single match (one round) plays only the `match` pop —
no combo cue. The `swap` cue plays for any committed gesture (legal or the reverted
ping-back); a legal move adds the `match` pop on its first clearing round; the
`win`/`lose` stinger fires once, edge-triggered, as the banner comes up.
### Format / level spec
Every cue is delivered in exactly the form System Sound Services loads directly:
**WAVE / mono / 44100 Hz / signed-16-bit PCM**. The pack sources are 24-bit /
48 kHz / stereo; each was down-mixed to mono, trimmed to its punchy window, eased
in with a short fade, rounded out with a cosine fade-out tail, and **peak-normalized
to a gentle 15 dBFS** (the user rejected aggressive SFX twice). The candy character
of the pack is preserved — the cues are not re-synthesized.
### Capturing the cue ordering
Every play is `NSLog`'d, so a playthrough's cue order is readable from the device
log. The deep cascade (`M3TE_FX=11`, depth-5 on seed 1337) must be launched **live**
(no `M3TE_ANIM_TIME` pin) so the timeline advances and the per-round cues fire in
sequence:
```bash
# Deep cascade, live — fires combo1..combo5 one per round on the timeline:
xcrun simctl terminate booted co.swipelab.m3te
SIMCTL_CHILD_M3TE_FX=11 xcrun simctl launch booted co.swipelab.m3te
xcrun simctl spawn booted log show --last 20s \
--predicate 'eventMessage CONTAINS "[sx] audio"' --style compact
# Win / lose stingers (also live so the banner edge-triggers the cue):
SIMCTL_CHILD_M3TE_TARGET=0 xcrun simctl launch booted co.swipelab.m3te # cue win
SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 xcrun simctl launch booted co.swipelab.m3te # cue lose
```
## Asset regeneration
The shipped game only loads finished assets; the steps below rebuild them and are
never run at play time. Refresh any affected `goldens/` after an art change.
### Audio (the SFX bank)
The shipped cues are the Triple Treat selections above, converted to the canonical
container with `afconvert` (WAVE / `LEI16` / 44100 / mono) and verified with
`tools/measure_pitch.py` — the combo cues' spectral centroids must ascend
monotonically (and beat `clear.wav`'s ~784 Hz CC0 baseline reference clip).
`tools/synth_audio.py` is the build-time DSP path (down-mix, trim, fade, peak-
normalize, optional resample). The 30 MB pack and its `.meta` / `__MACOSX` cruft
are not committed.
### Image art
Real art is produced with codex's built-in **`imagegen`** tool through `codex exec`,
then `sips`-normalized to each asset's exact dimensions and source format:
| asset | dims (px) | format | role |
|-----------------------------|-----------|--------|-----------------------------------|
| `assets/board/background.png` | 863×1822 | PNG | full-view candy gradient backdrop |
| `assets/board/cell.png` | 128×128 | PNG | one grid cell tile |
| `assets/gems/gems.png` | 768×128 | PNG | 6 gem columns (a gem's UV column = its index) |
| `assets/fx/particle.png` | 256×256 | PNG | soft match-burst sprite (tinted per gem) |
```bash
# 1. Generate (codex's imagegen tool, driven non-interactively):
codex exec "use the imagegen tool to render <prompt for the asset>"
# 2. Normalize to the exact per-asset dims + format, e.g. the gem sheet:
sips -z 128 768 --setProperty format png <generated>.png --out assets/gems/gems.png
```
After any art change, re-capture the affected goldens with the deterministic hooks
above (`M3TE_ANIM_TIME` / `M3TE_SELECT` / `M3TE_FX` / `M3TE_TARGET` /
`M3TE_MOVE_LIMIT` / `M3TE_RESTART`) and state per golden whether it was refreshed,
left unchanged, or removed.