Files
m3te/README.md
swipelab 5fa0a95cb4 P13.1: fix README cue-capture recipe — terminate before each env-pinned relaunch
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.
2026-06-06 09:12:10 +03:00

334 lines
18 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.
> **Every `M3TE_*` pin is read once, at app startup.** A `simctl launch` against
> an already-running copy just foregrounds the existing process (the launch prints
> the *same* PID) and the new `SIMCTL_CHILD_*` value is ignored. So every
> multi-launch recipe below passes `--terminate-running-process` on each pinned
> launch — it kills the running copy first, so the fresh process re-reads the new
> pin. (`xcrun simctl terminate booted co.swipelab.m3te` before each relaunch does
> the same.) A *changed* PID between launches confirms the new pin took.
```bash
# Resting board (idle at rest): goldens/p6_idle_t0.png
SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Mid-breath idle: goldens/p6_idle_mid.png
SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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 --terminate-running-process 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:
SIMCTL_CHILD_M3TE_FX=11 xcrun simctl launch --terminate-running-process 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). Each pinned
# launch passes --terminate-running-process: the M3TE_* pins are read only at
# startup, so relaunching a still-running copy reuses its PID and the new pin is
# ignored — without it the lose launch reuses the win process and only `cue win`
# ever prints. A changed PID between the two launches confirms each pin took:
SIMCTL_CHILD_M3TE_TARGET=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue win
SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 xcrun simctl launch --terminate-running-process 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.