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).
683 lines
38 KiB
Markdown
683 lines
38 KiB
Markdown
# 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.
|
||
|
||
### Organic swap motion (P16.1)
|
||
|
||
A legal swap no longer slides flatly into place: `render_swap` drives the two
|
||
gems with `ease_out_back` (the P15.1 overshoot curve), so they shoot a touch PAST
|
||
their target cells then settle exactly onto them. The curve pins `f(0)=0` and
|
||
`f(1)=1`, so the swap stays purely visual — `t==0` is the rest pose and `t==1`
|
||
lands byte-on-cell; the committed move and final board are unchanged. The reused
|
||
`SWAP_ANIM_DUR` (0.16 s) is untouched, so the cascade-cue timing snapshots
|
||
(`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
Capture the overshoot with the same `M3TE_FX` hook, pinned near the peak
|
||
(swap-phase `t ≈ 0.625`, where the gems are ~10 % past target):
|
||
|
||
```bash
|
||
# Swapped gems caught PAST their target cells (overshoot): goldens/p16_swap.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.10 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same swap at exact rest (t=0) — gems sit dead-on their pre-swap cells:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`M3TE_FX=3` is the top-row swap `(5,0)↔(6,0)` that completes the vertical red
|
||
3-match in column 5; at `M3TE_ANIM_TIME=0.10` the red lands ~8 % left of col-5
|
||
and the green ~12 % right of col-6 (every unswapped gem stays centered).
|
||
|
||
### Organic illegal swap (P16.2)
|
||
|
||
A *rejected* swap no longer pings flatly out and back: `render_swap` now drives the
|
||
two gems with `bad_swap_bounce` (a P15.1 `spring`-based envelope), so they lunge
|
||
toward each other then spring home, overshooting rest by a bounded amount before
|
||
settling. The curve pins `f(0)=0` and `f(1)=0`, so the move stays purely visual —
|
||
both `t=0` and `t=1` are the rest pose, the board is byte-identical to pre-swap, no
|
||
move or score is spent. The envelope (endpoints, single lunge peak + location,
|
||
damped settle) is locked headlessly by `tests/easing.sx`.
|
||
|
||
The bounce only plays for an *illegal* swap, which the sim can't script, so one more
|
||
env hook forces a known rejected pair at startup — combine it with `M3TE_ANIM_TIME`
|
||
to freeze the phase:
|
||
|
||
- `M3TE_BADSWAP=<n>` commits the **n-th currently-ILLEGAL orthogonally-adjacent
|
||
pair** — the complement of `legal_swaps`, enumerated in the SAME stable row-major
|
||
order (right-before-down), 1-based + clamped — through the normal `plan_and_commit`
|
||
path (which reverts the illegal swap), then begins the move timeline so the
|
||
swap-segment bounce can be screenshot. No FX begins (a rejected swap clears
|
||
nothing). Startup-only and guarded by the var, so normal play is byte-identical
|
||
with it unset.
|
||
|
||
The lunge peak is at swap-phase `t = BADSWAP_LUNGE_T (0.36)`, i.e. animation time
|
||
`0.36 × SWAP_ANIM_DUR (0.16) ≈ 0.0576 s`. For seed 1337, `M3TE_BADSWAP=41` is the
|
||
mid-board horizontal pair `(2,3)↔(3,3)`:
|
||
|
||
```bash
|
||
# Springy lunge caught at its extreme (gems nudged toward each other): goldens/p16_badswap.png
|
||
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0.0576 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same rejected pair at exact rest (t=0) — gems sit dead-on their pre-swap cells:
|
||
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
### Organic fall — gravity accel (P17.1)
|
||
|
||
Falling gems no longer *decelerate* into place: `render_fall` now drives the
|
||
per-round drop with `ease_in_cubic` (the P15.1 accelerate-from-rest curve) instead
|
||
of `ease_out_cubic`, so gems start slow and accelerate down like gravity. The curve
|
||
pins `f(0)=0` and `f(1)=1`, so each gem still lands EXACTLY on its destination cell
|
||
at the segment's end — the seam to the next round / settled board stays invisible
|
||
and `move.final` is untouched (`tests/anim_plan.sx` contiguity stays green).
|
||
`FALL_ANIM_DUR` (0.22 s) is unchanged, so the per-round cascade-cue timing
|
||
snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
The visual tell of the accel: pinned mid-fall, gems are bunched HIGH near their
|
||
sources (little distance covered) rather than spread out near landing. Capture it
|
||
with the `M3TE_FX` hook; `M3TE_FX=11` is the depth-5 cascade on seed 1337, pinned
|
||
inside **round 3's fall window** `[1.38, 1.60)` s — at `1.51` we are ~59 % through
|
||
that segment in time, yet `ease_in_cubic(0.59) ≈ 0.20`, so the gems have covered
|
||
only ~20 % of their drop and hang caught in the upper rows over the filled lower
|
||
board (the old `ease_out_cubic(0.59) ≈ 0.93` would have them ~93 % down, all but
|
||
landed):
|
||
|
||
```bash
|
||
# Gems caught bunched-high mid-fall under gravity accel: goldens/p17_fall.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.51 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same cascade past the timeline — fully 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 round-k fall window is `[0.30 + 0.36·k, 0.52 + 0.36·k)` s on the
|
||
swap→(clear,fall)\* timeline (swap 0.16, then clear 0.14 + fall 0.22 per round), so
|
||
`1.51` lands squarely in round 3's fall. The change is render-only — no `board.sx`
|
||
model change, and normal play is byte-identical apart from the fall's motion curve.
|
||
|
||
### Organic fall — per-column stagger (P17.2)
|
||
|
||
The fall no longer drops a whole row in lockstep: `render_fall` offsets each
|
||
COLUMN's drop START by a small bounded delay (`fall_stagger_t`), so a refilled row
|
||
pours in as a left-to-right cascade. Column `col` waits `FALL_STAGGER_MAX·col/7`
|
||
(`FALL_STAGGER_MAX = 0.30`) of the fall window, then falls over the remaining
|
||
`1 - 0.30`, feeding that local progress through `ease_in_cubic` so each column still
|
||
accelerates under gravity within its own window. 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 (`tests/anim_plan.sx` contiguity stays green;
|
||
`tests/easing.sx` pins `fall_stagger_t`'s `f(0)=0`, `f(1)=1`, monotonicity, and the
|
||
mid-fall cascade ordering). `FALL_ANIM_DUR` (0.22 s) is unchanged, so the per-round
|
||
cascade-cue snapshots don't churn.
|
||
|
||
The cleanest tell is a round where several adjacent columns refill the SAME
|
||
distance: in lockstep their gems share one height (a flat row); staggered, they
|
||
form a diagonal. On seed 1337, `M3TE_FX=11` **round 4** refills columns 2–7 by one
|
||
cell each (its fall window `[1.74, 1.96)` s); at `1.91` the leading column has just
|
||
landed while the trailing column is still ~⅔ cell high, so the top row reads as a
|
||
left-to-right staircase instead of a flat band:
|
||
|
||
```bash
|
||
# Refilled row pouring in as a staggered cascade: goldens/p17_stagger.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.91 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same cascade past the timeline — every gem landed exactly on the model board:
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
The change is render-only — no `board.sx` model change, and normal play is
|
||
byte-identical apart from the fall's per-column timing.
|
||
|
||
### Organic fall — landing squash-&-settle (P17.3)
|
||
|
||
Each landing gem now flattens **wide-and-short on impact then wobbles back to rest**
|
||
(P15.1's `squash_envelope`), applied WITHIN the fall so EVERY cascade round bounces,
|
||
staggered per column — not only the final whole-move settle as before. `render_fall`
|
||
ages a per-column bounce from each column's touch-down instant (`fall_landing_frac` ·
|
||
`FALL_ANIM_DUR`), so a gem still in the air is drawn unsquashed and only a gem that
|
||
has reached its cell flattens; the squash carries across the fall→clear seam
|
||
(`render_clear` continues the previous round's bounce) and across the final
|
||
render_anim → render_gems seam (the settle stamp is **back-dated** per column so
|
||
`land_squash` resumes exactly where the fall left it — ONE bounce, no double-pop).
|
||
`land_squash` is now `LAND_SQUASH_A · squash_envelope(tl/LAND_DUR)`, so the per-round
|
||
fall bounce and the settle bounce are the same single envelope; amplitude is the
|
||
tasteful ~13 % peak (`LAND_SQUASH_A = 0.18`). Durations are unchanged, so the
|
||
cascade-cue snapshots don't churn; `M3TE_ANIM_TIME=0` still reproduces
|
||
`goldens/p6_idle_t0.png` exactly (a resting board carries no landing stamp).
|
||
|
||
The visual tell: pin a round's fall just before it ends and the leading columns sit
|
||
landed-and-squashed (wide-short) while the trailing columns are still airborne — a
|
||
staggered squash wave. On seed 1337, `M3TE_FX=11` **round 4** (the refill round) at
|
||
`1.94` shows columns 2–4 landed and flattened with columns 5–7 still pouring in
|
||
(every round behaves identically — round 2 `[1.02,1.24)` at `1.21` and round 3
|
||
`[1.38,1.60)` near `1.58` bounce the same way, so the bounce is NOT limited to the
|
||
last settle):
|
||
|
||
```bash
|
||
# Staggered landing squash mid-pour (leading cols flattened, trailing airborne):
|
||
# goldens/p17_land.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.94 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same cascade past the timeline — fully settled, bounce decayed to rest (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
|
||
```
|
||
|
||
All three fall goldens were re-captured in P19.2 to the final, fully-merged fall
|
||
motion (gravity accel + per-column stagger + landing squash), each pinned to
|
||
foreground a different tell of it: `goldens/p17_fall.png` (round 3 at `1.51`) catches
|
||
the columns bunched HIGH under the gravity accel before any has landed (so it carries
|
||
no squash); `goldens/p17_stagger.png` (round 4 at `1.91`) catches the left-to-right
|
||
staircase as the leading column just touches down; and this golden (round 4 at `1.94`)
|
||
catches the squash wave once the leading columns have landed-and-flattened while the
|
||
trailing ones still pour in. The change is render-only — no `board.sx` model change,
|
||
and a resting board is untouched.
|
||
|
||
### Organic combine — anticipation pop on clear (P18.1)
|
||
|
||
Matched gems no longer just pop-then-shrink flatly: `clear_pop_scale` now shapes the
|
||
clear as a candy pop in three beats over its local `0..1` — a tiny anticipation
|
||
squash dip (a "gather" ~8 % below rest), a snappy overshoot up to ~1.40× via P15.1's
|
||
`ease_out_back`, then an accelerating collapse to nothing (`ease_in_quad`). The
|
||
endpoints stay LOCKED — `t==0 → 1.0` (rest) and `t==1 → 0.0` (gone) — so the seam to
|
||
the model board is clean and `M3TE_ANIM_TIME=0` still reproduces the resting board;
|
||
the soft particle burst / `+points` popup (`board_fx.sx`) compose on top.
|
||
`tests/gem_pose.sx` pins the new envelope (locked rest endpoints, the anticipation
|
||
dip below rest, the overshoot above 1, and the strictly monotonic post-peak
|
||
collapse). `CLEAR_ANIM_DUR` (0.14 s) is unchanged, so the per-round cascade-cue
|
||
timing snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
The pop peaks at clear-phase local `t ≈ 0.37`; for `M3TE_FX=3` (the seed-1337
|
||
vertical red 3-match in column 5, rows 0–2) the clear window is `[0.16, 0.30)` s.
|
||
Because P18.2 staggers each matched gem's pop START (see below), the `0.21` capture
|
||
no longer catches the three gems together at this shared peak — it catches them at
|
||
DIFFERENT points on this same curve, a ripple: the top gem collapsing, the middle
|
||
rising toward its overshoot, and the bottom still at rest (full size), all composed
|
||
with the burst and "+30" popup:
|
||
|
||
```bash
|
||
# Per-gem candy-pop shape, staggered across the match by P18.2 (composed w/ burst):
|
||
# goldens/p18_pop.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.21 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same match at exact rest (t=0) — board sits at its resting pose, no pop:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p6_fx_match.png` (the P11.1 burst+popup reference, pinned at `0.22`) was
|
||
refreshed for the new pop shape (and re-refreshed in P18.2 for the stagger — pinned
|
||
mid-clear, the committed frame now shows the ripple, not a uniform pop). This step is
|
||
the per-gem pop SHAPE only; the per-gem STAGGER of the explosions follows in P18.2.
|
||
The change is render-only — no `board.sx` model change, and normal play is
|
||
byte-identical apart from the clear's pop curve.
|
||
|
||
### Organic combine — staggered clear ripple (P18.2)
|
||
|
||
The matched gems no longer all explode at once: within a clearing round each gem's
|
||
pop (and its burst) START is offset by a small BOUNDED delay so the cells detonate
|
||
as a RIPPLE. `clear_ripple_t(t, u)` mirrors `fall_stagger_t`'s `(t-delay)/window`:
|
||
a gem's normalized rank `u ∈ [0,1]` (its diagonal `col+row` position within the
|
||
round's matched cells, lowest = `0`) delays its pop START by `CLEAR_STAGGER_MAX·u`
|
||
(`0.45` of the clear window), then plays the P18.1 `clear_pop_scale` curve over the
|
||
remaining `1 − CLEAR_STAGGER_MAX`. The rank is normalized PER ROUND (not across the
|
||
board) so even a 3-match ripples across the full stagger budget. It is BOUNDED:
|
||
every matched gem still reaches local `1` (scale `0`, fully cleared) by the clear
|
||
segment's end — the last-to-start gem (`u=1`) lands exactly at `t==1` — so no gem is
|
||
left mid-pop at the seam to the fall. The bursts (`board_fx.sx`) carry the SAME
|
||
per-gem delay so they ripple in lockstep with the pops. `tests/easing.sx` pins the
|
||
envelope (locked `f(0,·)=0` / `f(1,·)=1` endpoints, bounded completion by `t==1`,
|
||
monotonicity, and the rank ordering).
|
||
|
||
INTRA-ROUND VISUAL ONLY: the per-round cascade audio (P10.10) is untouched — one
|
||
ascending cue per round at the round's clear, NOT per gem — and the model is
|
||
unchanged (same cells cleared, same final board). `CLEAR_ANIM_DUR` (`0.14` s) is
|
||
unchanged, so the cascade-cue snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`)
|
||
don't churn and `M3TE_ANIM_TIME=0` still reproduces the resting board.
|
||
|
||
For `M3TE_FX=3` (the seed-1337 vertical red 3-match in column 5, rows 0–2) the clear
|
||
window is `[0.16, 0.30)` s; at `M3TE_ANIM_TIME=0.22` the ripple is at its clearest —
|
||
the TOP gem is collapsing, the MIDDLE is mid-burst, and the BOTTOM is still full-size
|
||
(not yet started):
|
||
|
||
```bash
|
||
# Staggered clear ripple (top collapsing / middle bursting / bottom not yet):
|
||
# goldens/p18_stagger.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Past the timeline — all three cells cleared per the model, board continues:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p18_pop.png` (`M3TE_FX=3` at `0.21`), `goldens/p6_fx_match.png` (`M3TE_FX=3`
|
||
at `0.22`), and `goldens/p11_combo_deep.png` (`M3TE_FX=11` at `0.22`) were refreshed
|
||
— each was pinned mid-clear, so each now shows the staggered ripple instead of the
|
||
prior simultaneous pop.
|
||
|
||
### FPS counter — dev overlay (P20.1)
|
||
|
||
A small FPS readout for gauging frame cost while tuning the animations. It is a
|
||
**dev overlay, OFF by default**: only the `M3TE_FPS` env pin turns it on, so default
|
||
play and every committed golden stay byte-identical (with `M3TE_FPS` unset the
|
||
rendered scene is unchanged — `goldens/p6_idle_t0.png` reproduces exactly).
|
||
|
||
- `M3TE_FPS=<non-zero>` renders the FPS counter in the **top-left corner** (inside
|
||
the safe area, clear of the centered notch / Dynamic Island and the centered HUD).
|
||
The rate is computed from the per-frame `delta_time` as an exponential moving
|
||
average (`FPS_DT_SMOOTH = 0.9`) so the digits don't jitter. Read once at startup
|
||
like every other `M3TE_*` pin; `=0` or unset leaves it off. Purely a render
|
||
overlay — no board / score / move / animation state changes, and it never gates
|
||
input. `delta_time` is real wall-clock even when `M3TE_ANIM_TIME` pins the
|
||
animation, so the counter stays live while the rest of the scene is frozen.
|
||
|
||
```bash
|
||
# FPS counter over the resting board: goldens/p20_fps.png
|
||
# (the FPS digits are DYNAMIC — only the FPS text varies run-to-run; the rest of the
|
||
# scene is pinned at M3TE_ANIM_TIME=0, byte-identical to goldens/p6_idle_t0.png)
|
||
env SIMCTL_CHILD_M3TE_FPS=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# On device, pass the same flag through devicectl's environment instead:
|
||
# xcrun devicectl device process launch --environment M3TE_FPS=1 ... co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p20_fps.png` is the only golden that captures this overlay; because the FPS
|
||
digits are dynamic, compare the FPS text's PRESENCE in the top-left corner, not its
|
||
exact value. Every other golden is captured with `M3TE_FPS` unset and is unaffected.
|
||
|
||
### Move-timeline frame goldens (P6.1 / P6.2)
|
||
|
||
The board-motion timeline (`board_anim.sx`: swap slide → matched-gem clear →
|
||
collapse/refill fall) and the match-FX layer (`board_fx.sx`) were first locked by a
|
||
set of frame goldens captured off ONE committed move. They use the same `M3TE_FX`
|
||
hook as the FX captures above — `M3TE_FX=3` is the seed-1337 vertical red 3-match (a
|
||
single round) — each pinned with `M3TE_ANIM_TIME` to a phase of the swap→clear→fall→
|
||
settled timeline. Re-captured in P19.2 so each now shows the **organic** motion merged
|
||
in P16–P18 (swap overshoot, anticipation-pop clear ripple, gravity-accel fall); the
|
||
canonical per-feature organic goldens live in the P16/P17/P18 sections above, and
|
||
these are the generic timeline frames at their segment midpoints.
|
||
|
||
```bash
|
||
# Swap segment midpoint — the swapped gems caught PAST target (ease_out_back
|
||
# overshoot): goldens/p6_anim_swap.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.08 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Clear segment midpoint — matched gems mid pop-ripple, composed with the burst and
|
||
# "+30" popup. The SAME committed-move frame backs all three of:
|
||
# goldens/p6_anim_clear.png == goldens/p6_fx.png == goldens/p6_inputlock_board.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.23 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Fall segment midpoint — the refilled column caught bunched high under gravity
|
||
# accel: goldens/p6_anim_fall.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.41 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Settled board past the timeline — FX fully pruned, the move's final board. The
|
||
# SAME settled frame backs all three of:
|
||
# goldens/p6_anim_after.png == goldens/p5_swap_after.png == goldens/p6_fx_after.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`p6_anim_clear` / `p6_fx` / `p6_inputlock_board` are one and the same frame — the
|
||
timeline's clear segment, the burst/popup FX, and the input-locked in-flight board
|
||
are all the same committed-move moment — as are the three settled `*_after` goldens.
|
||
|
||
The resting-board goldens — `goldens/p4_board.png`, `goldens/p4_hud.png`,
|
||
`goldens/p9_polish.png`, and `goldens/p5_swap_before.png` (the swap's start pose) —
|
||
are the seed-1337 board at rest, captured with no move committed:
|
||
|
||
```bash
|
||
# Resting candy board / HUD (no move): p4_board.png, p4_hud.png, p9_polish.png,
|
||
# p5_swap_before.png
|
||
SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
They match `goldens/p6_idle_t0.png` over the board+HUD region (the `t==0` rest
|
||
invariant, `tests/gem_pose.sx`); only the status-bar clock and the bottom
|
||
home-indicator chrome vary per grab, never the board or HUD.
|
||
|
||
## 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 **not synthesized** — each is a real **Triple Treat SFX** pack
|
||
clip selected per game event (per-cue source in `assets/audio/LICENSE.txt`). To
|
||
re-derive a cue from the pack, take its source file and apply the same DSP the
|
||
shipped bank used: down-mix to mono, trim to the punchy onset window (≤ ~600 ms),
|
||
ease in with a short fade, round out with a cosine fade-out tail, peak-normalize to
|
||
a gentle **−15 dBFS**, then re-wrap to the canonical container with `afconvert`
|
||
(`afconvert -f WAVE -d LEI16@44100 -c 1 in.wav out.wav` → WAVE / `LEI16` / 44100 /
|
||
mono). The combo ladder is five real **Match FX** cues ordered by ascending spectral
|
||
brightness (P10.9, `combo1`→`combo5`); the cascade plays one cue per round on the
|
||
animation timeline (P10.10). Verify the result read-only with `tools/measure_pitch.py`
|
||
(it never writes a WAV) — it prints each clip's dominant frequency, and `clear.wav`'s
|
||
~784 Hz CC0 reference is the documented baseline. The 30 MB pack and its `.meta` /
|
||
`__MACOSX` cruft are not committed.
|
||
|
||
> There is intentionally **no procedural-synthesis regeneration script**: the cues
|
||
> are the curated pack clips, so the original P10.1 note-frequency synthesizer
|
||
> (`tools/synth_audio.py`) was removed — running it would have clobbered the curated
|
||
> WAVs with synthetic audio.
|
||
|
||
### 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_BADSWAP` / `M3TE_TARGET` /
|
||
`M3TE_MOVE_LIMIT` / `M3TE_RESTART` / `M3TE_FPS`) and state per golden whether it was refreshed,
|
||
left unchanged, or removed.
|