Compare commits

...

10 Commits

Author SHA1 Message Date
swipelab
31d1012806 shed local vendors: stb + kb_text_shape + file_utils now ship with sx
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.
2026-06-12 18:35:12 +03:00
swipelab
39740a1d36 migrate to sx cstring era: std env() replaces local getenv/strlen, alloc_string rename
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.
2026-06-12 14:57:59 +03:00
swipelab
bb728d0ab0 migrate restart to opt-in UFCS (sx a47ea14)
Free function restart(board, seed) is dot-called from main.sx and
board_view.sx; the sx opt-in UFCS change gates plain functions out of
dot-dispatch, so declare it ufcs. ios-sim build green, 23/23 logic
tests.
2026-06-12 09:37:35 +03:00
swipelab
6f7d2f4db2 lang migration: rename signed integer types sN -> iN
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.
2026-06-12 09:36:51 +03:00
swipelab
1ab74c7d08 migrate allocator calls to alloc_bytes / libc_free 2026-06-12 09:34:13 +03:00
swipelab
38815c7d50 migrate to the restructured sx stdlib paths
modules/compiler.sx -> modules/build.sx; stb/stb_truetype/opengl/sdl3 ->
modules/ffi/; modules/process.sx -> modules/std/process.sx. Rebuilt for
macos + ios-sim; 23/23 logic snapshots pass.
2026-06-11 08:46:32 +03:00
swipelab
a7b41ccbca migrate to the new for-loop syntax
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.
2026-06-10 20:39:59 +03:00
swipelab
5a0627bb7c Merge branch 'm3te-plan' 2026-06-06 15:18:13 +03:00
swipelab
69e2c1f50d Merge branch 'flow/m3te/fix-final-2' into m3te-plan 2026-06-06 15:00:13 +03:00
swipelab
cd89a5c9c0 FX2: wire no-moves reshuffle into the UI swipe-commit path
The rendered swipe-commit path (`plan_and_commit`) bypassed the turn-loop's
no-moves rule: a deadlocked board (no legal swap) stayed stuck on screen because
only `play_turn` checked `!has_legal_swap` and reshuffled, and the UI never calls
`play_turn`.

Factor the post-settle "no legal swaps -> reshuffle" check into a shared
`reshuffle_if_deadlocked` in board.sx and call it from BOTH `play_turn` and
`plan_and_commit`, so the animated UI commit obeys the identical model rule. The
reshuffle runs after the cascade settles (post-`commit_swap`); the AnimMove's
recorded `final` stays the settled pre-reshuffle board, so the cascade animation,
per-round audio, and input gating are unchanged — the reshuffled layout renders on
the next settled frame. No win/lose/turn-accounting change; a reshuffle spends no
move and no score.

Regression test tests/swipe_reshuffle.sx drives the exact UI path (swipe_intent ->
plan_and_commit) on the deadlocked board from tests/level.sx: before = no legal
swaps / in_progress; after = reshuffled (has_legal_swap true, 9 legal swaps, no
immediate match), score/moves/budget unchanged. It FAILS pre-fix (board stays
stuck, has_legal_swap false) and PASSES post-fix.
2026-06-06 14:55:38 +03:00
52 changed files with 711 additions and 46087 deletions

29
.vscode/ios-sim-debug.sh vendored Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# preLaunchTask for "Debug SxChess (iOS sim)": build SxChess for the iOS
# simulator with debug info, install it to a single simulator, and launch it
# PAUSED for a debugger (--wait-for-debugger). The launch.json `custom` config
# then `target create`s the local binary (so the .dSYM resolves breakpoints)
# and `process attach -n SxChess`es the waiting process.
set -e
SX="/Users/agra/projects/sx/zig-out/bin/sx"
DSYMUTIL="/opt/homebrew/opt/llvm@19/bin/dsymutil"
# Reuse ONE simulator (sx-test-ios18). Swap the UDID for a different device.
SIM="E8DF755C-997D-49D7-9DB0-CFA48F8254CD"
APP="sx-out/ios/M3te.app"
BUNDLE="co.swipelab.m3te"
# Debug build: -O0 + DWARF (--emit-obj keeps the object), then a portable .dSYM.
"$SX" build --target ios-sim --emit-obj main.sx
"$DSYMUTIL" "$APP/M3te"
# Boot the sim (idempotent) and surface the Simulator UI.
xcrun simctl boot "$SIM" 2>/dev/null || true
xcrun simctl bootstatus "$SIM" >/dev/null 2>&1 || true
open -a Simulator
xcrun simctl install "$SIM" "$APP"
xcrun simctl terminate "$SIM" "$BUNDLE" >/dev/null 2>&1 || true
# Launch paused; the app waits at its entry until lldb attaches and continues.
xcrun simctl launch --wait-for-debugger "$SIM" "$BUNDLE"

27
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug M3te (macOS)",
"program": "${workspaceFolder}/sx-out/macos/M3te.app/Contents/MacOS/M3te",
"cwd": "${workspaceFolder}",
"preLaunchTask": "sx build (macos debug)"
},
{
"type": "lldb",
"request": "custom",
"name": "Debug M3te (iOS sim)",
"preLaunchTask": "sx build+launch (ios-sim debug)",
"targetCreateCommands": [
"target create '${workspaceFolder}/sx-out/ios/M3te.app/M3te'"
],
"processCreateCommands": [
"process attach -n M3te",
"process handle SIGSTOP --notify false --stop false --pass false",
"process continue"
]
}
]
}

23
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "sx build (macos debug)",
"detail": "Build M3te for macOS with DWARF (-O0) and collect a .dSYM for lldb.",
"type": "shell",
"command": "/Users/agra/projects/sx/zig-out/bin/sx build --target macos --emit-obj main.sx && /opt/homebrew/opt/llvm@19/bin/dsymutil sx-out/macos/M3te.app/Contents/MacOS/M3te",
"options": { "cwd": "${workspaceFolder}" },
"group": "build",
"problemMatcher": []
},
{
"label": "sx build+launch (ios-sim debug)",
"detail": "Build for the iOS simulator (-O0 + .dSYM), install, and launch paused for the debugger.",
"type": "shell",
"command": "${workspaceFolder}/.vscode/ios-sim-debug.sh",
"options": { "cwd": "${workspaceFolder}" },
"group": "build",
"problemMatcher": []
}
]
}

View File

@@ -10,17 +10,17 @@
// other targets never reference these symbols nor need the frameworks.
#import "modules/std.sx";
#import "modules/std/objc.sx";
#import "modules/compiler.sx";
#import "modules/ffi/objc.sx";
#import "modules/build.sx";
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign;
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> i32 #foreign;
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign;
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: i64, is_dir: i8) -> *void #foreign;
CFRelease :: (cf: *void) #foreign;
// libc — getcwd to absolutize the bundle-relative asset path. The platform
@@ -80,7 +80,7 @@ GameAudio :: struct {
// Pick the ascending cascade clip by clamping the cascade depth into the
// combo1..combo5 range (see `cascade_cue_index`).
play_cascade :: (self: *GameAudio, depth: s64) {
play_cascade :: (self: *GameAudio, depth: i64) {
inline if OS != .ios { return; }
if !self.loaded { return; }
idx := cascade_cue_index(depth);
@@ -107,7 +107,7 @@ GameAudio :: struct {
// `log show` shows the clip stepping up with cascade depth. Literals only — the
// string→NSString bridge needs NUL-terminated bytes (a formatted string may not
// be). `idx` is a clamped `cascade_cue_index`, so it is always 0..COMBO_CLIPS-1.
cascade_cue_name :: (idx: s64) -> string {
cascade_cue_name :: (idx: i64) -> string {
if idx <= 0 { return "[sx] audio: cue combo1"; }
if idx == 1 { return "[sx] audio: cue combo2"; }
if idx == 2 { return "[sx] audio: cue combo3"; }
@@ -118,7 +118,7 @@ cascade_cue_name :: (idx: s64) -> string {
// Cascade depth (number of cleared rounds) → combo clip index 0..COMBO_CLIPS-1
// (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and
// OS-agnostic so it can be snapshot-tested headlessly (P10.4).
cascade_cue_index :: (depth: s64) -> s64 {
cascade_cue_index :: (depth: i64) -> i64 {
if depth <= 1 { return 0; }
if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; }
depth - 1
@@ -145,7 +145,7 @@ load_system_sound :: (name: string) -> u32 {
if getcwd(@cwd_buf[0], 1024) == null { return 0; }
cwd : string = ---;
cwd.ptr = @cwd_buf[0];
cwd.len = cast(s64) c_strlen(@cwd_buf[0]);
cwd.len = cast(i64) c_strlen(@cwd_buf[0]);
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
// the formatted path needs no NUL terminator.
@@ -169,6 +169,6 @@ g_audio : *GameAudio = null;
sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } }
sfx_match :: () { if g_audio != null { g_audio.play_match(); } }
sfx_cascade :: (depth: s64) { if g_audio != null { g_audio.play_cascade(depth); } }
sfx_cascade :: (depth: i64) { if g_audio != null { g_audio.play_cascade(depth); } }
sfx_win :: () { if g_audio != null { g_audio.play_win(); } }
sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } }

181
board.sx
View File

@@ -39,36 +39,36 @@ EMPTY_CHAR :: 46; // '.'
gem_char :: (g: Gem) -> u8 {
if g == .empty { return EMPTY_CHAR; }
GEM_CHARS[cast(s64) g]
GEM_CHARS[cast(i64) g]
}
// ── Deterministic RNG ─────────────────────────────────────────────────────
// A 32-bit linear congruential generator (Numerical Recipes constants),
// carried in an s64 and masked back to 32 bits after every step so the
// carried in an i64 and masked back to 32 bits after every step so the
// stream is identical regardless of host integer width. The state*MUL+ADD
// product stays well under s64 range, so no intermediate overflow. Any seed
// product stays well under i64 range, so no intermediate overflow. Any seed
// (including 0) yields a valid stream — an LCG has no forbidden state.
RNG_MASK32 :: 0xFFFFFFFF;
RNG_MUL :: 1664525;
RNG_ADD :: 1013904223;
Rng :: struct {
state: s64;
state: i64;
// Advance and return the next 32-bit value.
next_u32 :: (self: *Rng) -> s64 {
next_u32 :: (self: *Rng) -> i64 {
self.state = (self.state * RNG_MUL + RNG_ADD) & RNG_MASK32;
self.state
}
// Uniform-ish value in [0, n). Uses the high bits, whose period is far
// longer than the low bits of an LCG.
next_range :: (self: *Rng, n: s64) -> s64 {
next_range :: (self: *Rng, n: i64) -> i64 {
(self.next_u32() >> 16) % n
}
}
rng_seeded :: (seed: s64) -> Rng {
rng_seeded :: (seed: i64) -> Rng {
Rng.{ state = seed & RNG_MASK32 }
}
@@ -106,7 +106,7 @@ Board :: struct {
// round's base points (see `score_round`), and `resolve` adds each cascade
// round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this
// field. A hand-built board must zero this before accumulating.
score: s64;
score: i64;
// Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED —
// only a legal swap (one that resolved into >=1 match) via `commit_swap`
@@ -115,15 +115,15 @@ Board :: struct {
// is derived from the two, so there is a single source of truth and the
// counters can never drift apart. A hand-built board must set both before
// committing swaps.
moves_made: s64;
move_limit: s64;
moves_made: i64;
move_limit: i64;
// Per-level score goal (P7.1). `init` sets it to DEFAULT_TARGET_SCORE;
// `level_status` reads it to decide a win (`score >= target_score`). A
// hand-built board must set this before its status is read.
target_score: s64;
target_score: i64;
idx :: (col: s64, row: s64) -> s64 {
idx :: (col: i64, row: i64) -> i64 {
row * BOARD_COLS + col
}
@@ -131,15 +131,15 @@ Board :: struct {
// to 0 when the budget is spent (and below it only if a caller keeps
// committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop
// (P7) reads this to decide when the game ends.
moves_remaining :: (self: *Board) -> s64 {
moves_remaining :: (self: *Board) -> i64 {
self.move_limit - self.moves_made
}
at :: (self: *Board, col: s64, row: s64) -> Gem {
at :: (self: *Board, col: i64, row: i64) -> Gem {
self.cells[Board.idx(col, row)]
}
set :: (self: *Board, col: s64, row: s64, g: Gem) {
set :: (self: *Board, col: i64, row: i64, g: Gem) {
self.cells[Board.idx(col, row)] = g;
}
@@ -149,14 +149,14 @@ Board :: struct {
// already-placed cells to its left or above is excluded, and the gem is
// drawn from the remaining allowed types. At most two types are ever
// excluded, so a choice always remains.
init :: (self: *Board, seed: s64) {
init :: (self: *Board, seed: i64) {
self.rng = rng_seeded(seed);
self.score = 0;
self.moves_made = 0;
self.move_limit = DEFAULT_MOVE_LIMIT;
self.target_score = DEFAULT_TARGET_SCORE;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
self.set(col, row, pick_gem(self, @self.rng, col, row));
}
}
@@ -165,31 +165,31 @@ Board :: struct {
// Choose a gem for (col, row) that can't extend an existing run leftward or
// upward. Pure given the board's already-placed prefix and the RNG state.
pick_gem :: (board: *Board, rng: *Rng, col: s64, row: s64) -> Gem {
pick_gem :: (board: *Board, rng: *Rng, col: i64, row: i64) -> Gem {
forbidden : [GEM_COUNT]bool = ---;
for 0..GEM_COUNT: (t) { forbidden[t] = false; }
for 0..GEM_COUNT (t) { forbidden[t] = false; }
// Two same gems immediately to the left → a third of that type matches.
if col >= 2 {
left := board.at(col - 1, row);
if left == board.at(col - 2, row) {
forbidden[cast(s64) left] = true;
forbidden[cast(i64) left] = true;
}
}
// Two same gems immediately above → a third of that type matches.
if row >= 2 {
up := board.at(col, row - 1);
if up == board.at(col, row - 2) {
forbidden[cast(s64) up] = true;
forbidden[cast(i64) up] = true;
}
}
allowed := 0;
for 0..GEM_COUNT: (t) { if !forbidden[t] { allowed += 1; } }
for 0..GEM_COUNT (t) { if !forbidden[t] { allowed += 1; } }
// Pick the k-th still-allowed type; single RNG draw, always terminates.
k := rng.next_range(allowed);
for 0..GEM_COUNT: (t) {
for 0..GEM_COUNT (t) {
if !forbidden[t] {
if k == 0 { return cast(Gem) t; }
k -= 1;
@@ -202,10 +202,10 @@ pick_gem :: (board: *Board, rng: *Rng, col: s64, row: s64) -> Gem {
// single gem character per cell. Suitable for snapshotting.
board_dump :: (self: *Board) -> string {
line_w := BOARD_COLS + 1; // 8 gem chars + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) {
buf := alloc_string(BOARD_ROWS * line_w);
for 0..BOARD_ROWS (row) {
base := row * line_w;
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
buf[base + col] = gem_char(self.at(col, row));
}
buf[base + BOARD_COLS] = 10; // '\n'
@@ -223,13 +223,13 @@ board_dump :: (self: *Board) -> string {
MatchMask :: struct {
cells: [BOARD_CELLS]bool;
at :: (self: *MatchMask, col: s64, row: s64) -> bool {
at :: (self: *MatchMask, col: i64, row: i64) -> bool {
self.cells[Board.idx(col, row)]
}
count :: (self: *MatchMask) -> s64 {
n : s64 = 0;
for 0..BOARD_CELLS: (i) { if self.cells[i] { n += 1; } }
count :: (self: *MatchMask) -> i64 {
n : i64 = 0;
for 0..BOARD_CELLS (i) { if self.cells[i] { n += 1; } }
n
}
}
@@ -237,8 +237,8 @@ MatchMask :: struct {
// Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal span, the column for a
// vertical one) and the span covers `start..end` of the moving coordinate.
mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
for start..end: (i) {
mark_run :: (m: *MatchMask, vertical: bool, fixed: i64, start: i64, end: i64) {
for start..end (i) {
if vertical {
m.cells[Board.idx(fixed, i)] = true;
} else {
@@ -259,10 +259,10 @@ mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
// break runs of real gems, since a hole differs from every gem type.
find_matches :: (b: *Board) -> MatchMask {
m : MatchMask = ---;
for 0..BOARD_CELLS: (i) { m.cells[i] = false; }
for 0..BOARD_CELLS (i) { m.cells[i] = false; }
// Horizontal: walk each row left-to-right in maximal same-type spans.
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
@@ -276,7 +276,7 @@ find_matches :: (b: *Board) -> MatchMask {
}
// Vertical: walk each column top-to-bottom in maximal same-type spans.
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
@@ -298,10 +298,10 @@ find_matches :: (b: *Board) -> MatchMask {
// unambiguously as the empty set. Suitable for snapshotting.
dump_matches :: (b: *Board, m: *MatchMask) -> string {
line_w := BOARD_COLS + 1; // 8 cells + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) {
buf := alloc_string(BOARD_ROWS * line_w);
for 0..BOARD_ROWS (row) {
base := row * line_w;
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
if m.at(col, row) {
buf[base + col] = gem_char(b.at(col, row));
} else {
@@ -317,8 +317,8 @@ dump_matches :: (b: *Board, m: *MatchMask) -> string {
// A board cell address. Kept separate from the row-major index so swap callers
// and the move enumeration speak in (col, row) like the rest of the model.
Cell :: struct {
col: s64;
row: s64;
col: i64;
row: i64;
}
// Exchange the gems of two cells, in place. `swap` is its own inverse: calling
@@ -371,8 +371,8 @@ Swap :: struct {
// the snapshot can depend on it.
legal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{};
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row };
@@ -397,7 +397,7 @@ legal_swaps :: (board: *Board) -> List(Swap) {
// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting.
dump_swaps :: (swaps: *List(Swap)) -> string {
result := format("{} legal swaps\n", swaps.len);
for 0..swaps.len: (i) {
for 0..swaps.len (i) {
s := swaps.items[i];
result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row));
}
@@ -413,9 +413,9 @@ dump_swaps :: (swaps: *List(Swap)) -> string {
// unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET
// from find_matches, so overlapping L/T shapes (already unioned into a single
// `true` per shared cell) clear as one set with no double-counting.
clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
cleared : s64 = 0;
for 0..BOARD_CELLS: (i) {
clear_cells :: (board: *Board, mask: *MatchMask) -> i64 {
cleared : i64 = 0;
for 0..BOARD_CELLS (i) {
if mask.cells[i] {
board.cells[i] = .empty;
cleared += 1;
@@ -428,7 +428,7 @@ clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
// cells cleared — 0 when there are no matches, in which case the board is left
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
// means the board changed and the resolution loop should continue.
clear_matches :: (board: *Board) -> s64 {
clear_matches :: (board: *Board) -> i64 {
m := find_matches(board);
clear_cells(board, @m)
}
@@ -449,7 +449,7 @@ clear_matches :: (board: *Board) -> s64 {
// this to know when gravity has stopped.
collapse :: (board: *Board) -> bool {
moved := false;
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
// Pack this column's gems toward the bottom: scan it bottom-to-top and
// write each gem at the falling cursor `w`, which also descends from the
// bottom. A gem whose source row differs from `w` actually fell. `w`
@@ -489,11 +489,11 @@ collapse :: (board: *Board) -> bool {
// only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with
// `.empty`; afterwards the board has no holes left. Returns the number of cells
// filled (0 on a board that had none).
refill :: (board: *Board) -> s64 {
refill :: (board: *Board) -> i64 {
rng := @board.rng;
filled : s64 = 0;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
filled : i64 = 0;
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
if board.at(col, row) == .empty {
board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT));
filled += 1;
@@ -526,11 +526,11 @@ refill :: (board: *Board) -> s64 {
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
// `had_len5_plus` are the boolean view of the same counts.
Cascade :: struct {
depth: s64;
cleared: List(s64);
awarded: s64;
len4: s64;
len5_plus: s64;
depth: i64;
cleared: List(i64);
awarded: i64;
len4: i64;
len5_plus: i64;
had_len4 :: (self: *Cascade) -> bool {
self.len4 > 0
@@ -546,7 +546,7 @@ Cascade :: struct {
// number of cells cleared this round — 0 iff the board was already stable, in
// which case nothing moves and no gem is drawn. `resolve` repeats this until it
// returns 0.
resolve_step :: (board: *Board) -> s64 {
resolve_step :: (board: *Board) -> i64 {
cleared := clear_matches(board);
if cleared == 0 { return 0; }
collapse(board);
@@ -560,7 +560,7 @@ resolve_step :: (board: *Board) -> s64 {
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
resolve :: (board: *Board) -> Cascade {
result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
result := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
while true {
// Read the round's base points AND its special-match tally while the runs
// are still on the board: `resolve_step` clears them, so both have to be
@@ -604,15 +604,15 @@ SCORE_RUN_5_PLUS :: 100;
// vertical one) and the run covers `start..start+len` of the moving coordinate.
Run :: struct {
vertical: bool;
fixed: s64;
start: s64;
len: s64;
fixed: i64;
start: i64;
len: i64;
}
// Base points for a single maximal run, by length. Runs are always length >= 3
// (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score
// the top tier.
run_score :: (len: s64) -> s64 {
run_score :: (len: i64) -> i64 {
if len <= 3 { return SCORE_RUN_3; }
if len == 4 { return SCORE_RUN_4; }
SCORE_RUN_5_PLUS
@@ -628,7 +628,7 @@ run_score :: (len: s64) -> s64 {
find_runs :: (b: *Board) -> List(Run) {
runs := List(Run).{};
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
@@ -643,7 +643,7 @@ find_runs :: (b: *Board) -> List(Run) {
}
}
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
@@ -666,10 +666,10 @@ find_runs :: (b: *Board) -> List(Run) {
// read-only — it inspects the board but changes nothing, so it must be called
// BEFORE the round's clear, while the runs are still on the board. A board with
// no run scores 0.
score_round :: (board: *Board) -> s64 {
score_round :: (board: *Board) -> i64 {
runs := find_runs(board);
total : s64 = 0;
for 0..runs.len: (i) {
total : i64 = 0;
for 0..runs.len (i) {
total += run_score(runs.items[i].len);
}
total
@@ -679,7 +679,7 @@ score_round :: (board: *Board) -> s64 {
// `score` total and return them. The single-round accumulation primitive; the
// cascade loop (`resolve`) instead scales each round by `combo_multiplier`
// (P3.2). Neither path changes `score_round`.
add_round_score :: (board: *Board) -> s64 {
add_round_score :: (board: *Board) -> i64 {
points := score_round(board);
board.score += points;
points
@@ -694,7 +694,7 @@ add_round_score :: (board: *Board) -> s64 {
// multi-round chain strictly beats the same clears scored flat. `resolve`
// accumulates `score_round * combo_multiplier(round)` per round into `Board.score`
// and reports the sum as `Cascade.awarded`.
combo_multiplier :: (round: s64) -> s64 {
combo_multiplier :: (round: i64) -> i64 {
round
}
@@ -711,8 +711,8 @@ combo_multiplier :: (round: s64) -> s64 {
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
// whole settle; a single round reads these counts directly.
SpecialCounts :: struct {
len4: s64;
len5_plus: s64;
len4: i64;
len5_plus: i64;
}
// Count the board's currently-matched runs that hit a special length, by the
@@ -724,7 +724,7 @@ SpecialCounts :: struct {
count_specials :: (board: *Board) -> SpecialCounts {
runs := find_runs(board);
counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
for 0..runs.len: (i) {
for 0..runs.len (i) {
len := runs.items[i].len;
if len == 4 {
counts.len4 += 1;
@@ -741,7 +741,7 @@ count_specials :: (board: *Board) -> SpecialCounts {
// "0 runs". Suitable for snapshotting.
dump_runs :: (runs: *List(Run)) -> string {
result := format("{} runs\n", runs.len);
for 0..runs.len: (i) {
for 0..runs.len (i) {
r := runs.items[i];
axis := if r.vertical then "V" else "H";
result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start));
@@ -766,7 +766,7 @@ dump_runs :: (runs: *List(Run)) -> string {
PlayerMove :: struct {
legal: bool;
cascade: Cascade;
moves_remaining: s64;
moves_remaining: i64;
}
// Attempt the player's intended swap of two adjacent cells. If the swap is legal
@@ -779,7 +779,7 @@ PlayerMove :: struct {
// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT.
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
if !swap_legal(board, a, b) {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
empty := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
}
swap(board, a, b);
@@ -829,8 +829,8 @@ level_status :: (board: *Board) -> Status {
// a throwaway list each call. The trial swaps inside `swap_legal` are reverted,
// so the board is left unchanged.
has_legal_swap :: (board: *Board) -> bool {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row };
@@ -882,11 +882,25 @@ reshuffle :: (board: *Board) -> bool {
false
}
// After a committed move's cascade has settled, recover a deadlocked board so the
// player is never stranded: if the level is still in progress yet no legal swap
// remains, `reshuffle` the gems in place. A reshuffle is NOT a move and never runs
// on a finished (won/lost) level, so win/lose and turn accounting are untouched.
// Returns whether a reshuffle ran. BOTH the headless turn loop (`play_turn`) and
// the animated UI commit (`plan_and_commit`) call this, so the rendered game obeys
// the identical no-moves rule — neither path can leave the board stuck.
reshuffle_if_deadlocked :: (board: *Board) -> bool {
if level_status(board) == .in_progress and !has_legal_swap(board) {
return reshuffle(board);
}
false
}
// Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same
// seed → identical starting layout), zeroes `score` and `moves_made`, and
// restores the default move budget and score goal, so `level_status` reads
// `in_progress` again. The entry point P7.2's restart button calls.
restart :: (board: *Board, seed: s64) {
restart :: ufcs (board: *Board, seed: i64) {
board.init(seed);
}
@@ -916,14 +930,11 @@ TurnResult :: struct {
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
status := level_status(board);
if status != .in_progress {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
empty := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
}
move := commit_swap(board, a, b);
reshuffled := false;
if level_status(board) == .in_progress and !has_legal_swap(board) {
reshuffled = reshuffle(board);
}
reshuffled := reshuffle_if_deadlocked(board);
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
}

View File

@@ -1,12 +1,13 @@
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
// one player move. The logical model (commit_swap / resolve) stays authoritative:
// `plan_and_commit` commits the move on the real board exactly as before, then
// replays the SAME operations on a value-copy of the pre-move board to record the
// per-step geometry (the swap, each cascade round's matched cells, and each
// round's per-column fall provenance). Because the copy starts from the identical
// cells AND RNG state and runs the identical primitives, its recorded `final`
// board equals the model's settled board gem-for-gem — the animation only ever
// ends ON the already-decided result, never changes it.
// `plan_and_commit` commits the move on the real board (and, like the headless
// turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
// commit operations on a value-copy of the pre-move board to record the per-step
// geometry (the swap, each cascade round's matched cells, and each round's
// per-column fall provenance). Because the copy starts from the identical cells
// AND RNG state and runs the identical primitives, its recorded `final` board
// equals the move's settled (pre-reshuffle) board gem-for-gem — the animation only
// ever ends ON the already-decided cascade result, never changes it.
//
// Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX
// (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched
@@ -108,7 +109,7 @@ bad_swap_bounce :: (t: f32) -> f32 {
// ease_in_cubic so each column still accelerates under gravity within its window.
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
FALL_STAGGER_MAX :f32: 0.30;
fall_stagger_t :: (t: f32, col: s64) -> f32 {
fall_stagger_t :: (t: f32, col: i64) -> f32 {
delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1));
window := 1.0 - FALL_STAGGER_MAX;
lt := (t - delay) / window;
@@ -122,7 +123,7 @@ fall_stagger_t :: (t: f32, col: s64) -> f32 {
// at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing
// squash-bounce (P17.3) ages from this instant per column, so the squash begins
// the moment a gem touches its cell rather than at a flat whole-row settle.
fall_landing_frac :: (col: s64) -> f32 {
fall_landing_frac :: (col: i64) -> f32 {
(1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1))
}
@@ -131,7 +132,7 @@ fall_landing_frac :: (col: s64) -> f32 {
// per-round bounce ages from. Round k's fall starts after the swap, k clear+fall
// pairs, and that round's own clear; column `col` then lands `fall_landing_frac`
// of the fall window into it. Pure + headless, mirrors `phase`'s segment walk.
round_land_time :: (k: s64, col: s64) -> f32 {
round_land_time :: (k: i64, col: i64) -> f32 {
SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR
+ fall_landing_frac(col) * FALL_ANIM_DUR
}
@@ -158,11 +159,11 @@ clear_ripple_t :: (t: f32, u: f32) -> f32 {
// The diagonal (col+row) extent of a round's matched cells — the span the ripple
// ranks each matched gem across. `hi < lo` only if the mask is empty.
ClearDiag :: struct { lo: s64; hi: s64; }
ClearDiag :: struct { lo: i64; hi: i64; }
clear_diag_span :: (m: *MatchMask) -> ClearDiag {
lo : s64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
hi : s64 = -1;
for 0..BOARD_CELLS: (i) {
lo : i64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
hi : i64 = -1;
for 0..BOARD_CELLS (i) {
if m.cells[i] {
d := (i % BOARD_COLS) + (i / BOARD_COLS);
if d < lo { lo = d; }
@@ -177,7 +178,7 @@ clear_diag_span :: (m: *MatchMask) -> ClearDiag {
// PER ROUND (not across the board) lets even a small 3-match ripple across the
// full stagger budget. A degenerate span (every matched cell on one diagonal)
// ranks all gems 0, so they pop together rather than dividing by zero.
clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 {
clear_rank :: (span: ClearDiag, col: i64, row: i64) -> f32 {
if span.hi <= span.lo { return 0.0; }
cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo)
}
@@ -192,7 +193,7 @@ clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 {
AnimRound :: struct {
before: [BOARD_CELLS]Gem;
matched: MatchMask;
src: [BOARD_CELLS]s64;
src: [BOARD_CELLS]i64;
after: [BOARD_CELLS]Gem;
}
@@ -209,7 +210,7 @@ AnimMove :: struct {
pre: [BOARD_CELLS]Gem;
rounds: List(AnimRound);
final: [BOARD_CELLS]Gem;
awarded: s64;
awarded: i64;
}
// The most recent round at or before `kmax` that dropped a MOVED gem onto
@@ -220,7 +221,7 @@ AnimMove :: struct {
// ages from its LATEST arrival, never a stale earlier one. Pure + headless: the
// per-round bounce (render_fall/clear) and the final-settle stamp share this so
// one envelope plays continuously across every seam.
delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
delivering_round :: (mv: *AnimMove, i: i64, kmax: i64) -> i64 {
row := i / BOARD_COLS;
k := kmax;
while k >= 0 {
@@ -231,9 +232,13 @@ delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
}
// Commit the player's swap authoritatively AND record its visual timeline. The
// real board is mutated by `commit_swap` exactly as the non-animated path did;
// the recording runs on a separate value-copy taken BEFORE the commit, so it
// replays the identical cells + RNG stream and its `final` equals `board.cells`.
// real board is mutated by `commit_swap`, then — exactly like the headless
// `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
// game obeys the same no-moves rule. The recording runs on a value-copy taken
// BEFORE the commit, so it replays the identical cells + RNG stream; the recorded
// `final` is the SETTLED board the animation ends on. It equals the live board
// unless a deadlock reshuffle then re-arranged it: that reshuffle is a model step,
// not part of this move's timeline, so it renders on the next settled frame.
plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move : AnimMove = ---;
move.a = a;
@@ -251,6 +256,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move.awarded = mv.cascade.awarded;
if !mv.legal {
move.final = board.cells;
reshuffle_if_deadlocked(board);
return move;
}
@@ -271,7 +277,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
// came from source row `r`. The rows left above the survivors (0..w) are
// refilled, so they drop in from above: a dest row `j` there starts at
// `j - n_refill`, i.e. stacked just off the top edge.
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1;
while r >= 0 {
@@ -296,6 +302,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
}
move.final = scratch.cells;
reshuffle_if_deadlocked(board);
move
}
@@ -305,7 +312,7 @@ AnimPhaseKind :: enum { swap; clear; fall; done; }
AnimPhase :: struct {
kind: AnimPhaseKind;
round: s64;
round: i64;
t: f32;
}
@@ -320,7 +327,7 @@ BoardAnim :: struct {
// so the frame loop's per-round SFX is edge-triggered: a round's cue fires once,
// when its clear begins, never re-fired every frame. Reset whenever a move
// (re)starts; advanced by the frame loop as rounds clear.
cascade_fired: s64;
cascade_fired: i64;
init :: (self: *BoardAnim) {
self.active = false;
@@ -355,7 +362,7 @@ BoardAnim :: struct {
return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR };
}
e -= SWAP_ANIM_DUR;
for 0..self.move.rounds.len: (k) {
for 0..self.move.rounds.len (k) {
if e < CLEAR_ANIM_DUR {
return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR };
}
@@ -376,11 +383,11 @@ BoardAnim :: struct {
// have fired by now (clamped to the move's round count). The frame loop diffs it
// against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure +
// headless so the per-round playback is snapshot-testable without audio.
cascade_rounds_started :: (elapsed: f32, num_rounds: s64) -> s64 {
cascade_rounds_started :: (elapsed: f32, num_rounds: i64) -> i64 {
if num_rounds <= 0 { return 0; }
if elapsed < SWAP_ANIM_DUR { return 0; }
seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR;
started := cast(s64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1;
started := cast(i64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1;
if started > num_rounds { return num_rounds; }
started
}

View File

@@ -12,8 +12,8 @@
// shrink to nothing) rather than alpha — the soft texture edges carry the fade.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/ffi/opengl.sx";
#import "vendors/stb_image/stb_image.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
@@ -48,7 +48,7 @@ FX_COMBO_LABEL_GAP :f32: 0.12; // gap (cell units) between the label and +poin
// order (red, orange, yellow, green, blue, purple). Saturated a touch past the
// pastel — the low channel is trimmed while the dominant/mid channel is lifted —
// so every burst pops as a punchier colour without losing luminance.
fx_tint :: (i: s64) -> Color {
fx_tint :: (i: i64) -> Color {
if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; }
if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; }
if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; }
@@ -67,7 +67,7 @@ FX_POPUP_COMBO_HOT :: Color.{ r = 255, g = 248, b = 214, a = 255 }; // hot n
// lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the
// equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx).
FX_COMBO_MAX_LEVEL :: 4; // == audio.sx COMBO_CLIPS - 1
fx_combo_level :: (depth: s64) -> s64 {
fx_combo_level :: (depth: i64) -> i64 {
if depth <= 1 { return 0; }
if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; }
depth - 1
@@ -76,14 +76,14 @@ fx_combo_level :: (depth: s64) -> s64 {
// Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1)
// uses the plain size; a combo starts at the base combo size and grows one step
// per combo level past the first, clamped at the deepest level.
fx_popup_font :: (depth: s64) -> f32 {
fx_popup_font :: (depth: i64) -> f32 {
if depth <= 1 { return FX_POPUP_FONT; }
FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1)
}
// Popup colour for a cascade `depth` rounds deep: white for a single clear, else
// the gold lerped toward a hot near-white as the cascade deepens.
fx_popup_color :: (depth: s64) -> Color {
fx_popup_color :: (depth: i64) -> Color {
if depth <= 1 { return FX_POPUP_COLOR; }
t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1);
Color.{
@@ -101,7 +101,7 @@ fx_lerp_u8 :: (lo: u8, hi: u8, t: f32) -> u8 {
// Upload an RGBA buffer as a texture, returning its handle. Mirrors
// board_view.load_texture's upload half but takes an in-memory buffer (the
// per-colour tinted particle) instead of a file path.
upload_rgba :: (pixels: [*]u8, w: s32, h: s32, gpu: ?GPU) -> u32 {
upload_rgba :: (pixels: [*]u8, w: i32, h: i32, gpu: ?GPU) -> u32 {
if gpu != null {
return xx gpu.create_texture(w, h, .rgba8, xx pixels);
}
@@ -123,28 +123,28 @@ BoardFxAssets :: struct {
loaded: bool;
init :: (self: *BoardFxAssets) {
for 0..GEM_COUNT: (t) { self.tex[t] = 0; }
for 0..GEM_COUNT (t) { self.tex[t] = 0; }
self.loaded = false;
}
load :: (self: *BoardFxAssets, gpu: ?GPU) {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
w : i32 = 0;
h : i32 = 0;
ch : i32 = 0;
src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
if xx src == 0 {
out("WARNING: could not load assets/fx/particle.png\n");
self.loaded = false;
return;
}
n := cast(s64) w * cast(s64) h;
buf : [*]u8 = xx context.allocator.alloc(n * 4);
n := cast(i64) w * cast(i64) h;
buf : [*]u8 = xx context.allocator.alloc_bytes(n * 4);
// Loop locals are hoisted: a block-scoped local declared inside a body
// that runs hundreds of thousands of times grows the stack per iteration
// (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars.
i : s64 = 0;
o : s64 = 0;
for 0..GEM_COUNT: (t) {
i : i64 = 0;
o : i64 = 0;
for 0..GEM_COUNT (t) {
col := fx_tint(t);
i = 0;
while i < n {
@@ -168,7 +168,7 @@ BoardFxAssets :: struct {
FxParticle :: struct {
col: f32;
row: f32;
tint: s64;
tint: i64;
delay: f32;
age: f32;
life: f32;
@@ -184,8 +184,8 @@ FxParticle :: struct {
FxPopup :: struct {
col: f32;
row: f32;
points: s64;
depth: s64;
points: i64;
depth: i64;
delay: f32;
age: f32;
life: f32;
@@ -219,7 +219,7 @@ BoardFx :: struct {
// Whole-move depth boost: a deeper cascade makes every burst bigger from
// its first round, escalating in lockstep with the cascade SFX cue.
depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len);
for 0..mv.rounds.len: (k) {
for 0..mv.rounds.len (k) {
rd := @mv.rounds.items[k];
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2);
@@ -227,7 +227,7 @@ BoardFx :: struct {
// bursts ripple in lockstep with the staggered pops (P18.2) instead of
// one simultaneous flash. The round's audio cue still fires once at t0.
span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (idx) {
for 0..BOARD_CELLS (idx) {
if rd.matched.cells[idx] {
g := rd.before[idx];
if g != .empty {
@@ -237,7 +237,7 @@ BoardFx :: struct {
self.particles.append(FxParticle.{
col = cast(f32) col + 0.5,
row = cast(f32) row + 0.5,
tint = cast(s64) g,
tint = cast(i64) g,
delay = t0 + rdelay,
age = 0.0,
life = FX_BURST_LIFE,
@@ -250,10 +250,10 @@ BoardFx :: struct {
// One popup for the whole move at the first clear's centroid.
rd0 := @mv.rounds.items[0];
sc : s64 = 0;
sr : s64 = 0;
cnt : s64 = 0;
for 0..BOARD_CELLS: (idx) {
sc : i64 = 0;
sr : i64 = 0;
cnt : i64 = 0;
for 0..BOARD_CELLS (idx) {
if rd0.matched.cells[idx] {
sc += idx % BOARD_COLS;
sr += idx / BOARD_COLS;
@@ -275,8 +275,8 @@ BoardFx :: struct {
// Advance every live FX by `dt` and drop those past their lifetime. Kept
// simple: compact each list in place by overwriting dead entries.
tick :: (self: *BoardFx, dt: f32) {
w : s64 = 0;
i : s64 = 0;
w : i64 = 0;
i : i64 = 0;
while i < self.particles.len {
p := self.particles.items[i];
p.age += dt;

View File

@@ -25,7 +25,7 @@ BoardLayout :: struct {
};
}
cell_frame :: (self: *BoardLayout, col: s64, row: s64) -> Frame {
cell_frame :: (self: *BoardLayout, col: i64, row: i64) -> Frame {
Frame.make(
self.origin.x + xx col * self.cell_size,
self.origin.y + xx row * self.cell_size,
@@ -44,8 +44,8 @@ BoardLayout :: struct {
fx := (p.x - self.origin.x) / self.cell_size;
fy := (p.y - self.origin.y) / self.cell_size;
if fx < 0.0 or fy < 0.0 { return null; }
col : s64 = xx fx;
row : s64 = xx fy;
col : i64 = xx fx;
row : i64 = xx fy;
if col >= BOARD_COLS or row >= BOARD_ROWS { return null; }
Cell.{ col = col, row = row }
}

View File

@@ -6,8 +6,8 @@
// the grid is a centered square inside the safe-area inset.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/ffi/opengl.sx";
#import "vendors/stb_image/stb_image.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
@@ -126,7 +126,7 @@ BoardAssets :: struct {
self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
}
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
gem_uv :: (self: *BoardAssets, index: i64) -> GemUV {
u0 : f32 = xx index * self.cell_u;
GemUV.{
uv_min = Point.{ x = u0, y = 0.0 },
@@ -139,9 +139,9 @@ BoardAssets :: struct {
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
w : i32 = 0;
h : i32 = 0;
ch : i32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4);
if pixels == null {
out("WARNING: could not load texture: ");
@@ -237,7 +237,7 @@ BoardView :: struct {
safe: EdgeInsets;
// Seed for `restart`: the same fixed seed main seeded the board with, so the
// restart button reproduces the identical starting level.
seed: s64;
seed: i64;
// FPS dev overlay (P20.1). `fps_on` gates the corner readout (off by default,
// set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame
// rate computed in the frame loop. Purely a render overlay.
@@ -266,7 +266,7 @@ BoardView :: struct {
}
// Draw gem `gem_index`'s sprite-sheet column into `gf`.
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) {
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: i64) {
uv := self.assets.gem_uv(gem_index);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
}
@@ -285,7 +285,7 @@ BoardView :: struct {
// Frame for a gem shrunk by `scale` about its cell centre — the clear
// scale-out. At scale 0 the gem is a zero-size frame (gone).
gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame {
gem_frame_scaled :: (self: *BoardView, col: i64, row: i64, dim: f32, scale: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5;
cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5;
@@ -297,7 +297,7 @@ BoardView :: struct {
// sprite is scaled about its cell centre and nudged by the pose offset (both
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
// idle pose draws identically to the static sprite.
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
gem_pose_frame :: (self: *BoardView, col: i64, row: i64, dim: f32, pose: GemPose) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
@@ -311,7 +311,7 @@ BoardView :: struct {
// — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred
// placement EXACTLY, so a gem still mid-fall (or one that never moved) draws
// byte-identically to the plain fall; only a landed gem flattens.
gem_squash_frame :: (self: *BoardView, col: s64, frow: f32, dim: f32, sq: f32) -> Frame {
gem_squash_frame :: (self: *BoardView, col: i64, frow: f32, dim: f32, sq: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs;
cy := self.layout.origin.y + (frow + 0.5) * cs;
@@ -323,7 +323,7 @@ BoardView :: struct {
// The per-gem animation pose for a settled cell: the always-on idle breath,
// plus a squash-bounce if the cell landed recently, plus a pop if it is the
// selected cell. Purely visual — composed from gem_anim's pure functions.
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
gem_pose_at :: (self: *BoardView, col: i64, row: i64) -> GemPose {
pose := idle_pose(self.motion.clock, col, row);
sq := land_squash(self.motion.land_local(Board.idx(col, row)));
@@ -347,7 +347,7 @@ BoardView :: struct {
// (land_squash → 0, so it draws unsquashed) and one that never moved reads 0.
// render_fall passes the current round; render_clear the previous (its board is
// that round's `after`), so the one bounce plays on across the fall→clear seam.
rest_squash :: (self: *BoardView, i: s64, kmax: s64, elapsed: f32) -> f32 {
rest_squash :: (self: *BoardView, i: i64, kmax: i64, elapsed: f32) -> f32 {
m := delivering_round(@self.anim.move, i, kmax);
if m < 0 { return 0.0; }
col := i % BOARD_COLS;
@@ -357,13 +357,13 @@ BoardView :: struct {
// Settled-board gems: one sprite per non-empty cell, drawn with its live
// per-gem animation pose. Used whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
g := self.board.at(col, row);
if g != .empty {
pose := self.gem_pose_at(col, row);
gf := self.gem_pose_frame(col, row, dim, pose);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
}
}
}
@@ -431,12 +431,12 @@ BoardView :: struct {
// (which resumes the same back-dated stamp). tick() normally clears
// `active` before this is reached.
last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
g := mv.final[i];
if g != .empty {
sq := self.rest_squash(i, last, e);
gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
}
}
}
@@ -452,12 +452,12 @@ BoardView :: struct {
ai := Board.idx(mv.a.col, mv.a.row);
bi := Board.idx(mv.b.col, mv.b.row);
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if i == ai or i == bi { continue; }
g := mv.pre[i];
if g != .empty {
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
}
}
@@ -482,20 +482,20 @@ BoardView :: struct {
ga := mv.pre[ai];
if ga != .empty {
gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) ga);
self.draw_gem(ctx, gf, cast(i64) ga);
}
gb := mv.pre[bi];
if gb != .empty {
gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) gb);
self.draw_gem(ctx, gf, cast(i64) gb);
}
}
// Clear segment: matched gems pop outward then collapse to nothing (a
// satisfying pop, composing with the particle burst); the rest hold position.
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
g := rd.before[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
@@ -507,7 +507,7 @@ BoardView :: struct {
// reaches scale 0 by t==1, keeping the seam to the fall clean.
pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row)));
gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
} else {
// before[k] is round k-1's settled board, so a survivor here still
// carries the bounce from the round that dropped it in — continue it
@@ -515,7 +515,7 @@ BoardView :: struct {
// (nothing has fallen yet), keeping that frame byte-identical.
sq := self.rest_squash(i, k - 1, e);
gf := self.gem_squash_frame(col, cast(f32) row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
}
}
}
@@ -532,7 +532,7 @@ BoardView :: struct {
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
for 0..self.fx.particles.len: (i) {
for 0..self.fx.particles.len (i) {
p := self.fx.particles.items[i];
lt := (p.age - p.delay) / p.life;
env := fx_pop_env(lt);
@@ -556,7 +556,7 @@ BoardView :: struct {
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fx.popups.len == 0 { return; }
cs := self.layout.cell_size;
for 0..self.fx.popups.len: (i) {
for 0..self.fx.popups.len (i) {
q := self.fx.popups.items[i];
lt := (q.age - q.delay) / q.life;
if lt >= 0.0 {
@@ -593,8 +593,8 @@ BoardView :: struct {
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its
// cell and the seam to the next round / settled board stays invisible.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
for 0..BOARD_CELLS: (i) {
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
for 0..BOARD_CELLS (i) {
g := rd.after[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
@@ -608,7 +608,7 @@ BoardView :: struct {
// has reached its cell flattens wide-and-short, then wobbles out.
sq := self.rest_squash(i, k, e);
gf := self.gem_squash_frame(col, cur_row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
self.draw_gem(ctx, gf, cast(i64) g);
}
}
@@ -668,7 +668,7 @@ BoardView :: struct {
// pin) is set, so the unset render path is byte-identical. A bright halo under
// the dark text keeps the digits legible over the light background art.
render_fps_overlay :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
n := cast(s64) (self.fps + 0.5);
n := cast(i64) (self.fps + 0.5);
txt := format("FPS {}", n);
sz := measure_text(txt, FPS_FONT);
x := frame.origin.x + self.safe.left + FPS_PAD;
@@ -731,8 +731,8 @@ impl View for BoardView {
gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
if self.assets.cell_tex != 0 {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
}
}

View File

@@ -1,4 +1,4 @@
#import "modules/compiler.sx";
#import "modules/build.sx";
#import "modules/platform/bundle.sx";
configure_build :: () {

View File

@@ -25,9 +25,9 @@ the step asks for an honest accounting.
## Category: Numerics / parsing
### 1. String → number parsing (`parse_s64`, `parse_f32`)
- **m3te location:** `main.sx:156` (`parse_s64`), `main.sx:122` (`parse_f32`).
- **What it does:** decimal ASCII `string``s64` / `f32` (sign + integer +
### 1. String → number parsing (`parse_i64`, `parse_f32`)
- **m3te location:** `main.sx:156` (`parse_i64`), `main.sx:122` (`parse_f32`).
- **What it does:** decimal ASCII `string``i64` / `f32` (sign + integer +
optional fractional part).
- **Why general-purpose:** the exact inverse of the formatters everyone already
uses; needed by any program that reads numbers from argv, env, config, or a
@@ -46,7 +46,7 @@ the step asks for an honest accounting.
- **m3te location:** `board.sx:55` (`Rng` struct: `next_u32` `board.sx:59`,
`next_range` `board.sx:66`), `board.sx:71` (`rng_seeded`), constants
`board.sx:51-53`.
- **What it does:** a 32-bit linear-congruential generator carried in `s64`
- **What it does:** a 32-bit linear-congruential generator carried in `i64`
and masked to 32 bits — `next_u32()`, `next_range(n)` (uniform-ish `[0,n)`),
`rng_seeded(seed)`.
- **Why general-purpose:** a seedable, reproducible RNG is a textbook stdlib
@@ -172,7 +172,7 @@ them, so they are explicitly **not** counted above:
## Summary (top gaps, ranked)
1. **String → number parsing** (`parse_s64` / `parse_f32`) — clean, universally
1. **String → number parsing** (`parse_i64` / `parse_f32`) — clean, universally
needed, the missing inverse of the existing formatters.
2. **Seedable PRNG** (`Rng` LCG) — textbook stdlib primitive, entirely absent.
3. **`from_cstr` C-string bridge** (+ public `strlen`) — the most-repeated FFI

View File

@@ -38,11 +38,11 @@ IDLE_BOB_A :f32: 0.024; // vertical bob amplitude (cell units)
IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest
// Smooth per-cell phase: a diagonal gradient wrapped into one breath period.
gem_idle_phase :: (col: s64, row: s64) -> f32 {
gem_idle_phase :: (col: i64, row: i64) -> f32 {
cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU
}
idle_pose :: (t: f32, col: s64, row: s64) -> GemPose {
idle_pose :: (t: f32, col: i64, row: i64) -> GemPose {
ramp := clamp(t / IDLE_RAMP, 0.0, 1.0);
w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row);
s := IDLE_SCALE_A * sin(w) * ramp;
@@ -132,10 +132,10 @@ GemMotion :: struct {
// its resting pose instead of replaying the prior move's landing wobble; the
// idle clock keeps running, so the always-on idle simply resumes from rest.
reset_landings :: (self: *GemMotion) {
for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; }
for 0..BOARD_CELLS (i) { self.land_at[i] = -1000.0; }
}
stamp_land :: (self: *GemMotion, i: s64) {
stamp_land :: (self: *GemMotion, i: i64) {
self.stamp_land_at(i, self.clock);
}
@@ -143,11 +143,11 @@ GemMotion :: struct {
// can BACK-DATE the stamp to when the gem actually touched down mid-fall (each
// column lands at a staggered instant): land_squash then resumes the per-round
// bounce exactly where render_fall left it, with no double-pop at the seam.
stamp_land_at :: (self: *GemMotion, i: s64, at: f32) {
stamp_land_at :: (self: *GemMotion, i: i64, at: f32) {
self.land_at[i] = at;
}
land_local :: (self: *GemMotion, i: s64) -> f32 {
land_local :: (self: *GemMotion, i: i64) -> f32 {
self.clock - self.land_at[i]
}
}

97
main.sx
View File

@@ -1,11 +1,13 @@
#import "modules/std.sx";
#import "build.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/sdl3.sx";
#import "modules/build.sx";
#import "modules/ffi/opengl.sx";
#import "modules/ffi/sdl3.sx";
#import "modules/math";
#import "modules/stb.sx";
#import "modules/stb_truetype.sx";
#import "vendors/stb_image/stb_image.sx";
#import "vendors/stb_truetype/stb_truetype.sx";
#import "vendors/kb_text_shape/kb_text_shape.sx";
#import "vendors/file_utils/file_utils.sx";
#import "modules/gpu/api.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/metal.sx";
@@ -24,8 +26,6 @@
// libc is the implicit foreign-library handle the std allocators bind against;
// reused here to read the deterministic-capture environment variables at startup.
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
strlen :: (s: *u8) -> usize #foreign libc "strlen";
// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
@@ -116,41 +116,34 @@ build_ui :: () -> View {
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
// be captured without injecting a tap. Absent → normal live behaviour.
read_env :: (name: [:0]u8) -> ?string {
p := getenv(name);
addr : s64 = xx p;
if addr == 0 { return null; }
n := cast(s64) strlen(p);
if n == 0 { return ""; }
buf := cstring(n);
memcpy(buf.ptr, xx p, n);
buf
process.env(name)
}
// Digit arithmetic runs entirely in s64; the result converts to f32 only once at
// Digit arithmetic runs entirely in i64; the result converts to f32 only once at
// the end. Doing the digit math in f32 would unify the ASCII literals (45/46/48/
// 57) to f32 across the comparisons, which mis-types the byte compares.
parse_f32 :: (s: string) -> f32 {
i : s64 = 0;
i : i64 = 0;
neg : bool = false;
if s.len > 0 {
c0 : s64 = xx s[0];
c0 : i64 = xx s[0];
if c0 == 45 { neg = true; i = 1; } // '-'
}
intval : s64 = 0;
intval : i64 = 0;
while i < s.len {
c : s64 = xx s[i];
c : i64 = xx s[i];
if c < 48 or c > 57 { break; }
intval = intval * 10 + (c - 48);
i += 1;
}
fracval : s64 = 0;
fracdiv : s64 = 1;
fracval : i64 = 0;
fracdiv : i64 = 1;
if i < s.len {
d : s64 = xx s[i];
d : i64 = xx s[i];
if d == 46 { // '.'
i += 1;
while i < s.len {
c : s64 = xx s[i];
c : i64 = xx s[i];
if c < 48 or c > 57 { break; }
fracval = fracval * 10 + (c - 48);
fracdiv = fracdiv * 10;
@@ -163,11 +156,11 @@ parse_f32 :: (s: string) -> f32 {
v
}
parse_s64 :: (s: string) -> s64 {
i : s64 = 0;
v : s64 = 0;
parse_i64 :: (s: string) -> i64 {
i : i64 = 0;
v : i64 = 0;
while i < s.len {
c : s64 = xx s[i];
c : i64 = xx s[i];
if c < 48 or c > 57 { break; }
v = v * 10 + (c - 48);
i += 1;
@@ -183,8 +176,8 @@ parse_s64 :: (s: string) -> s64 {
// trial swaps inside `swap_legal` are reverted, so the board is left unchanged.
illegal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{};
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row };
@@ -222,7 +215,7 @@ frame :: () {
g_pipeline.resize(fc.viewport_w, fc.viewport_h);
}
for g_plat.poll_events(): (*ev) {
for g_plat.poll_events() (*ev) {
inline if OS != .ios {
if ev == {
case .key_up: (e) {
@@ -277,7 +270,7 @@ frame :: () {
mv := @g_anim.move;
total := g_anim.total();
last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
m := delivering_round(mv, i, last);
if m >= 0 {
col := i % BOARD_COLS;
@@ -327,7 +320,7 @@ frame :: () {
main :: () -> void {
inline if OS == .ios {
u : *UIKitPlatform = xx context.allocator.alloc(size_of(UIKitPlatform));
u : *UIKitPlatform = xx context.allocator.alloc_bytes(size_of(UIKitPlatform));
u.gpu_mode = .metal;
if !u.init("m3te", 800, 600) { return; }
g_plat = xx u;
@@ -337,13 +330,13 @@ main :: () -> void {
// return into UIApplicationMain, so attach lazily on the first frame.
// init(null, 0, 0) only needs the MTLDevice, which is enough for the
// texture uploads below.
g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU));
g_metal_gpu = xx context.allocator.alloc_bytes(size_of(MetalGPU));
// alloc returns uninitialized memory; struct field defaults are NOT
// applied, so List caps/lens would be garbage without this memset.
memset(xx g_metal_gpu, 0, size_of(MetalGPU));
if !g_metal_gpu.init(null, 0, 0) { return; }
} else {
s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform));
s : *SdlPlatform = xx context.allocator.alloc_bytes(size_of(SdlPlatform));
if !s.init("m3te", 800, 600) { return; }
g_plat = xx s;
}
@@ -353,7 +346,7 @@ main :: () -> void {
g_viewport_h = fc.viewport_h;
g_safe_insets = g_plat.safe_insets();
g_pipeline = xx context.allocator.alloc(size_of(UIPipeline));
g_pipeline = xx context.allocator.alloc_bytes(size_of(UIPipeline));
// Same alloc caveat as above: zero so the optional `gpu` reads as null on
// the desktop path (where set_gpu is not called) and the Lists start empty.
memset(xx g_pipeline, 0, size_of(UIPipeline));
@@ -363,37 +356,37 @@ main :: () -> void {
g_pipeline.init(fc.viewport_w, fc.viewport_h);
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale);
g_board = xx context.allocator.alloc(size_of(Board));
g_board = xx context.allocator.alloc_bytes(size_of(Board));
g_board.init(BOARD_SEED);
g_assets = xx context.allocator.alloc(size_of(BoardAssets));
g_assets = xx context.allocator.alloc_bytes(size_of(BoardAssets));
g_assets.init();
g_assets.load(g_pipeline.gpu);
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
g_sel = xx context.allocator.alloc_bytes(size_of(BoardSelection));
g_sel.init();
g_drag = xx context.allocator.alloc(size_of(DragInput));
g_drag = xx context.allocator.alloc_bytes(size_of(DragInput));
g_drag.init();
g_anim = xx context.allocator.alloc(size_of(BoardAnim));
g_anim = xx context.allocator.alloc_bytes(size_of(BoardAnim));
g_anim.init();
g_fx = xx context.allocator.alloc(size_of(BoardFx));
g_fx = xx context.allocator.alloc_bytes(size_of(BoardFx));
g_fx.init();
g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
g_fxassets = xx context.allocator.alloc_bytes(size_of(BoardFxAssets));
g_fxassets.init();
g_fxassets.load(g_pipeline.gpu);
g_motion = xx context.allocator.alloc(size_of(GemMotion));
g_motion = xx context.allocator.alloc_bytes(size_of(GemMotion));
g_motion.init();
// SFX (P10.2). Loads the System Sound Services cue bank once; board_view
// plays a cue per event. Purely additive — never touches score/board/move
// state. On iOS the platform has already chdir'd to the bundle, so each
// cue's relative path resolves. No-op off iOS.
g_audio = xx context.allocator.alloc(size_of(GameAudio));
g_audio = xx context.allocator.alloc_bytes(size_of(GameAudio));
memset(xx g_audio, 0, size_of(GameAudio));
g_audio.init();
@@ -405,7 +398,7 @@ main :: () -> void {
g_motion.clock = parse_f32(t);
}
if sc := read_env("M3TE_SELECT") {
idx := parse_s64(sc);
idx := parse_i64(sc);
if idx >= 0 and idx < BOARD_CELLS {
g_sel.active = true;
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
@@ -418,7 +411,7 @@ main :: () -> void {
// committed golden stay byte-identical. Purely a render overlay — no board /
// score / move / animation state changes and it never gates input.
if fp := read_env("M3TE_FPS") {
if parse_s64(fp) != 0 { g_fps_on = true; }
if parse_i64(fp) != 0 { g_fps_on = true; }
}
// Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move,
@@ -433,7 +426,7 @@ main :: () -> void {
if fx := read_env("M3TE_FX") {
swaps := legal_swaps(g_board);
if swaps.len > 0 {
n := parse_s64(fx);
n := parse_i64(fx);
if n < 1 { n = 1; }
if n > swaps.len { n = swaps.len; }
sw := swaps.items[n - 1];
@@ -457,7 +450,7 @@ main :: () -> void {
if bs := read_env("M3TE_BADSWAP") {
bad := illegal_swaps(g_board);
if bad.len > 0 {
n := parse_s64(bs);
n := parse_i64(bs);
if n < 1 { n = 1; }
if n > bad.len { n = bad.len; }
sw := bad.items[n - 1];
@@ -473,10 +466,10 @@ main :: () -> void {
// M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With
// M3TE_RESTART set non-zero the board is then restart()-ed, capturing the
// fresh in_progress board the restart button produces.
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); }
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); }
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_i64(tg); }
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_i64(ml); }
if rs := read_env("M3TE_RESTART") {
if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); }
if parse_i64(rs) != 0 { g_board.restart(BOARD_SEED); }
}
g_pipeline.set_body(closure(build_ui));

View File

@@ -24,14 +24,14 @@
SEED :: 1337;
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> s32 {
fails : s64 = 0;
main :: () -> i32 {
fails : i64 = 0;
// ── Legal swap: plan == model, timeline ends on the model ───────────────
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
@@ -61,7 +61,7 @@ main :: () -> s32 {
// move.final equals the model board.
final_eq := true;
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
}
if !final_eq { fails += 1; }
@@ -74,21 +74,21 @@ main :: () -> s32 {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
r0 := @move.rounds.items[0];
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
expect : Gem = move.pre[i];
if i == ai { expect = move.pre[bi]; }
else if i == bi { expect = move.pre[ai]; }
if !(r0.before[i] == expect) { contiguous = false; }
}
for 1..move.rounds.len: (k) {
for 1..move.rounds.len (k) {
prev := @move.rounds.items[k - 1];
cur := @move.rounds.items[k];
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
}
}
last := @move.rounds.items[move.rounds.len - 1];
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(last.after[i] == move.final[i]) { contiguous = false; }
}
}

View File

@@ -4,7 +4,7 @@
#import "modules/std.sx";
t :: #import "test.sx";
main :: () -> s32 {
main :: () -> i32 {
t.expect(2 + 2 == 4, "two plus two is four");
t.expect(7 % 3 == 1, "seven mod three is one");
t.expect(10 - 4 == 6, "ten minus four is six");

View File

@@ -13,16 +13,17 @@
// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero
// exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
irect :: (f: Frame) -> string {
format("({},{},{},{})",
cast(s64) f.origin.x, cast(s64) f.origin.y,
cast(s64) f.size.width, cast(s64) f.size.height)
cast(i64) f.origin.x, cast(i64) f.origin.y,
cast(i64) f.size.width, cast(i64) f.size.height)
}
main :: () -> s32 {
main :: () -> i32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the
// same layout tests/hit_test.sx pins, so the numbers are checkable by hand.
lay : BoardLayout = ---;
@@ -35,18 +36,18 @@ main :: () -> s32 {
print("title {}\n", irect(bl.title));
print("button {}\n", irect(bl.button));
fails : s64 = 0;
fails : i64 = 0;
// The button is horizontally centered on the grid (centred banner).
bcx := bl.button.mid_x();
if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; }
print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x());
if cast(i64) bcx != cast(i64) grid.mid_x() { fails += 1; }
print("button mid_x {} grid mid_x {}\n", cast(i64) bcx, cast(i64) grid.mid_x());
// The whole button sits inside the panel — its four corners are contained,
// so it can never spill outside the drawn card.
bx0 := bl.button.origin.x; by0 := bl.button.origin.y;
bx1 := bl.button.max_x(); by1 := bl.button.max_y();
corners_in : s64 = 0;
corners_in : i64 = 0;
if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; }
@@ -66,7 +67,7 @@ main :: () -> s32 {
// in the button, so each leaves the level frozen.
corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 };
outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() };
off_hits : s64 = 0;
off_hits : i64 = 0;
if bl.button.contains(corner_cell) { off_hits += 1; }
if bl.button.contains(outside) { off_hits += 1; }
if off_hits != 0 { fails += 1; }

View File

@@ -10,16 +10,16 @@ SEED :: 1337;
// Count every horizontal or vertical window of three consecutive same-type
// gems. A correctly initialized board has zero. This walks the finished board
// independently of the placement logic, so it's a real check, not a tautology.
count_three_runs :: (b: *Board) -> s32 {
runs : s32 = 0;
for 0..BOARD_ROWS: (row) {
for 0..(BOARD_COLS - 2): (col) {
count_three_runs :: (b: *Board) -> i32 {
runs : i32 = 0;
for 0..BOARD_ROWS (row) {
for 0..(BOARD_COLS - 2) (col) {
g := b.at(col, row);
if g == b.at(col + 1, row) and g == b.at(col + 2, row) { runs += 1; }
}
}
for 0..(BOARD_ROWS - 2): (row) {
for 0..BOARD_COLS: (col) {
for 0..(BOARD_ROWS - 2) (row) {
for 0..BOARD_COLS (col) {
g := b.at(col, row);
if g == b.at(col, row + 1) and g == b.at(col, row + 2) { runs += 1; }
}
@@ -27,7 +27,7 @@ count_three_runs :: (b: *Board) -> s32 {
runs
}
main :: () -> s32 {
main :: () -> i32 {
board : Board = ---;
board.init(SEED);

View File

@@ -24,7 +24,7 @@ EXPECTED_DEPTH :: 2;
// board can be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -34,9 +34,9 @@ char_to_gem :: (c: u8) -> Gem {
// The RNG is left unseeded — callers seed it before resolving.
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -44,7 +44,7 @@ load_board :: (rows: []string) -> Board {
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
@@ -84,7 +84,7 @@ checker_board :: () -> Board {
b
}
main :: () -> s32 {
main :: () -> i32 {
print("== cascade (resolution loop) ==\n");
// Drive the loop one round at a time so each post-round board is visible in
@@ -94,7 +94,7 @@ main :: () -> s32 {
out(board_dump(@b));
depth := 0;
counts := List(s64).{};
counts := List(i64).{};
while true {
n := resolve_step(@b);
if n == 0 { break; }
@@ -120,7 +120,7 @@ main :: () -> s32 {
t.expect(c.depth == depth, "cascade: resolve depth matches manual loop");
same_counts := c.cleared.len == counts.len;
if same_counts {
for 0..counts.len: (i) {
for 0..counts.len (i) {
if c.cleared.items[i] != counts.items[i] { same_counts = false; }
}
}

View File

@@ -8,14 +8,14 @@
#import "modules/std.sx";
#import "audio.sx";
main :: () -> s32 {
main :: () -> i32 {
print("== cascade cue selection (depth -> combo cue) ==\n");
// Walk a representative depth range (0..9) so both clamps and the monotonic
// middle are visible: depths 0,1 pin to the first cue; depths >= 5 pin to
// the last; 2,3,4 step up one cue at a time.
prev : s64 = -1;
for 0..10: (depth) {
prev : i64 = -1;
for 0..10 (depth) {
idx := cascade_cue_index(depth);
print("depth {} -> idx {} ({})\n", depth, idx, cascade_cue_name(idx));
// The mapping must never step down as depth grows.

View File

@@ -13,7 +13,7 @@
#import "board_anim.sx";
#import "audio.sx";
main :: () -> s32 {
main :: () -> i32 {
print("== per-round cascade cue timing ==\n");
// `cascade_rounds_started` = how many cascade rounds have BEGUN clearing by
@@ -21,7 +21,7 @@ main :: () -> s32 {
// Round k (0-based) starts clearing at 0.16 + k*0.36; sampled safely INSIDE
// each round window so the integer step is unambiguous. Locked for 5 rounds.
print("-- started-count across a 5-round chain --\n");
rounds : s64 = 5;
rounds : i64 = 5;
print("e=0.00 -> {}\n", cascade_rounds_started(0.00, rounds));
print("e=0.10 -> {}\n", cascade_rounds_started(0.10, rounds));
print("e=0.20 -> {}\n", cascade_rounds_started(0.20, rounds));
@@ -37,7 +37,7 @@ main :: () -> s32 {
// ascending. This IS the loop main's frame loop runs; the emitted run is the
// locked acceptance ordering.
print("-- ascending per-round run --\n");
fired : s64 = 0;
fired : i64 = 0;
elapsed : f32 = 0.0;
while fired < rounds {
started := cascade_rounds_started(elapsed, rounds);
@@ -57,7 +57,7 @@ main :: () -> s32 {
// Deep chain: the cue tail clamps at combo5 for round >= 5 (cascade_cue_index).
print("-- deep-chain cue clamp --\n");
for 1..8: (r) { print("round {} -> {}\n", r, cascade_cue_name(cascade_cue_index(r))); }
for 1..8 (r) { print("round {} -> {}\n", r, cascade_cue_name(cascade_cue_index(r))); }
print("ok: one ascending combo cue per cascade round, clamped at combo5\n");
return 0;

View File

@@ -16,7 +16,7 @@ t :: #import "test.sx";
// clear) for the holes-never-match regression.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
// characters).
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -38,7 +38,7 @@ load_board :: (rows: []string) -> Board {
// Detect→clear one scene, snapshot before/after, and assert the three clear
// invariants against the matched-cell set: every flagged cell is now a hole,
// every unflagged cell is unchanged, and the returned count is exact.
scene :: (name: string, rows: []string, want_cleared: s64) {
scene :: (name: string, rows: []string, want_cleared: i64) {
b := load_board(rows);
orig := load_board(rows); // pristine copy for the unchanged check
@@ -53,7 +53,7 @@ scene :: (name: string, rows: []string, want_cleared: s64) {
cleared_holes := true; // every matched cell is now a hole
others_intact := true; // every other cell is byte-identical
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if m.cells[i] {
if !(b.cells[i] == .empty) { cleared_holes = false; }
} else {
@@ -65,7 +65,7 @@ scene :: (name: string, rows: []string, want_cleared: s64) {
t.expect(cleared == want_cleared, concat(name, ": cleared count exact"));
}
main :: () -> s32 {
main :: () -> i32 {
print("== clear (detect -> clear) ==\n");
// Single horizontal 3-run (row 3, cols 2-4) → three holes there only.

View File

@@ -18,7 +18,7 @@ t :: #import "test.sx";
// maps to `.empty`, so a board can be hand-written with holes in any position.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -27,9 +27,9 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars).
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -42,15 +42,15 @@ load_board :: (rows: []string) -> Board {
// them a hole. This single check covers holes-bubble-to-top, gems-settle-to-
// bottom, order-preservation, and the all-holes / no-holes edge columns at once.
check_collapsed :: (orig: *Board, b: *Board) -> bool {
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
gems : [BOARD_ROWS]Gem = ---;
n := 0;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
g := orig.at(col, row);
if g != .empty { gems[n] = g; n += 1; }
}
boundary := BOARD_ROWS - n; // first row that must hold a gem
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
if row < boundary {
if b.at(col, row) != .empty { return false; }
} else {
@@ -79,7 +79,7 @@ scene :: (name: string, rows: []string, want_moved: bool) {
t.expect(moved == want_moved, concat(name, ": moved flag exact"));
}
main :: () -> s32 {
main :: () -> i32 {
print("== collapse (gravity) ==\n");
// Eight independent columns, one case each (top-to-bottom):

View File

@@ -23,7 +23,7 @@ t :: #import "test.sx";
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -31,11 +31,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed so `board.score` ends equal to the payout.
load_board :: (rows: []string, seed: s64) -> Board {
load_board :: (rows: []string, seed: i64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -51,14 +51,14 @@ load_board :: (rows: []string, seed: s64) -> Board {
// identical board awards `want_mult` into `Board.score` and reports it as
// `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum
// (no bonus); a deeper chain must strictly exceed it.
scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s64) {
scene :: (name: string, rows: []string, seed: i64, want_flat: i64, want_mult: i64) {
print("== {} ==\n", name);
b := load_board(rows, seed);
out(board_dump(@b));
flat : s64 = 0;
mult : s64 = 0;
depth : s64 = 0;
flat : i64 = 0;
mult : i64 = 0;
depth : i64 = 0;
while true {
base := score_round(@b);
n := resolve_step(@b);
@@ -90,7 +90,7 @@ scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s6
t.expect(b2.score == want_mult, concat(name, ": resolve accumulates into board.score"));
}
main :: () -> s32 {
main :: () -> i32 {
print("== combo (cascade multiplier) ==\n");
// Single-round clear (seed 0): one RRR clears and the refill makes no new

View File

@@ -19,8 +19,8 @@
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 {
fails : s64 = 0;
main :: () -> i32 {
fails : i64 = 0;
// 1. Endpoints are locked: every curve starts/ends exactly on its rest value
// (the in/out curves at 1, the spring at 1, the squash envelope at 0).
@@ -51,7 +51,7 @@ main :: () -> s32 {
p_io := ease_in_out_cubic(0.0);
p_oc := ease_out_cubic(0.0);
p_iq := ease_in_quad(0.0);
for 1..21: (i) {
for 1..21 (i) {
t := cast(f32) i / 20.0;
v_in := ease_in_cubic(t); if v_in < p_in - 0.000001 { mono_in = false; } p_in = v_in;
v_io := ease_in_out_cubic(t); if v_io < p_io - 0.000001 { mono_io = false; } p_io = v_io;
@@ -72,7 +72,7 @@ main :: () -> s32 {
back_mx := ease_out_back(0.0); back_mn := ease_out_back(0.0);
spr_mx := spring(0.0); spr_mn := spring(0.0);
spr_wobble := false;
for 1..21: (i) {
for 1..21 (i) {
t := cast(f32) i / 20.0;
b := ease_out_back(t);
if b > back_mx { back_mx = b; }
@@ -98,7 +98,7 @@ main :: () -> s32 {
// squash (positive) and a stretch (negative) lobe, and stays bounded.
print("== squash envelope bounded ==\n");
sq_mx : f32 = 0.0; sq_mn : f32 = 0.0; sq_moves := false;
for 0..21: (i) {
for 0..21 (i) {
t := cast(f32) i / 20.0;
s := squash_envelope(t);
if s > sq_mx { sq_mx = s; }
@@ -121,7 +121,7 @@ main :: () -> s32 {
print("== illegal-swap bounce ==\n");
bb_ends := bad_swap_bounce(0.0) == 0.0 and bad_swap_bounce(1.0) == 0.0;
bb_mx : f32 = 0.0; bb_mx_t : f32 = 0.0; bb_mn : f32 = 0.0;
for 0..101: (i) {
for 0..101 (i) {
t := cast(f32) i / 100.0;
v := bad_swap_bounce(t);
if v > bb_mx { bb_mx = v; bb_mx_t = t; }
@@ -151,18 +151,18 @@ main :: () -> s32 {
// starts later), the opposite of a flat lockstep row sharing one progress.
print("== fall stagger bounded ==\n");
stg_t0 := true; stg_t1 := true;
for 0..BOARD_COLS: (c) {
for 0..BOARD_COLS (c) {
if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; }
if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; }
}
stg_cascade := true;
for 1..BOARD_COLS: (c) {
for 1..BOARD_COLS (c) {
if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; }
}
stg_mono := true;
for 0..BOARD_COLS: (c) {
for 0..BOARD_COLS (c) {
pp := fall_stagger_t(0.0, c);
for 1..21: (i) {
for 1..21 (i) {
tt := cast(f32) i / 20.0;
vv := fall_stagger_t(tt, c);
if vv < pp - 0.000001 { stg_mono = false; }
@@ -190,14 +190,14 @@ main :: () -> s32 {
lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0);
lf_mono := true;
lf_seam := true;
for 0..BOARD_COLS: (c) {
for 0..BOARD_COLS (c) {
if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; }
lf := fall_landing_frac(c);
if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf
if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before
}
rlt_col_mono := true;
for 1..BOARD_COLS: (c) {
for 1..BOARD_COLS (c) {
if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; }
}
rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1);
@@ -222,22 +222,22 @@ main :: () -> s32 {
// gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop).
print("== clear ripple bounded ==\n");
rip_t0 := true; rip_t1 := true;
for 0..6: (j) {
for 0..6 (j) {
u := cast(f32) j / 5.0;
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
}
rip_ripple := true;
for 1..6: (j) {
for 1..6 (j) {
u := cast(f32) j / 5.0;
up := cast(f32) (j - 1) / 5.0;
if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; }
}
rip_mono := true;
for 0..6: (j) {
for 0..6 (j) {
u := cast(f32) j / 5.0;
pp := clear_ripple_t(0.0, u);
for 1..21: (i) {
for 1..21 (i) {
tt := cast(f32) i / 20.0;
vv := clear_ripple_t(tt, u);
if vv < pp - 0.000001 { rip_mono = false; }
@@ -245,7 +245,7 @@ main :: () -> s32 {
}
}
mm : MatchMask = ---;
for 0..BOARD_CELLS: (i) { mm.cells[i] = false; }
for 0..BOARD_CELLS (i) { mm.cells[i] = false; }
mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop
mm.cells[Board.idx(5, 1)] = true; // diagonal 6
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,23 @@
== deadlocked board: UI swipe-commit path must reshuffle ==
ROYGBPRO
PROYGBPR
BPROYGBP
GBPROYGB
YGBPROYG
OYGBPROY
ROYGBPRO
PROYGBPR
before: matches 0 legal_swaps 0 has_legal_swap false status in_progress
intent (0,0)->(1,0)
commit: legal false rounds 0 awarded 0
after: matches 0 legal_swaps 9 has_legal_swap true status in_progress
after: score 0 moves_made 0 moves_remaining 10
BGGYORYR
RRYGOPBY
YRYBPRGB
OOBGBPRG
RPRPYRPO
OBBPOOPG
OBGGOPGY
YPRYBORP
ok: UI swipe-commit path reshuffles a deadlocked board

View File

@@ -13,15 +13,15 @@
#import "modules/std.sx";
#import "board_fx.sx";
main :: () -> s32 {
main :: () -> i32 {
print("== combo emphasis selection (depth -> fx level / popup font) ==\n");
// The cascade-cue index per depth 0..9, copied from cascade_cue.stdout. The
// FX level must equal this entry for entry — the audio/visual lockstep.
expect_level : [10]s64 = .{ 0, 0, 1, 2, 3, 4, 4, 4, 4, 4 };
expect_level : [10]i64 = .{ 0, 0, 1, 2, 3, 4, 4, 4, 4, 4 };
prev : s64 = -1;
for 0..10: (depth) {
prev : i64 = -1;
for 0..10 (depth) {
lvl := fx_combo_level(depth);
font := fx_popup_font(depth);
combo := depth > 1;
@@ -45,7 +45,7 @@ main :: () -> s32 {
// larger and the font never shrinks as the cascade deepens.
if fx_popup_font(1) != FX_POPUP_FONT { print("FAIL: single-clear popup not plain font\n"); return 1; }
pf : f32 = 0.0;
for 2..10: (depth) {
for 2..10 (depth) {
f := fx_popup_font(depth);
if f <= FX_POPUP_FONT { print("FAIL: combo popup not larger than plain at depth {}\n", depth); return 1; }
if depth > 2 and f < pf { print("FAIL: popup font shrank at depth {}\n", depth); return 1; }

View File

@@ -18,14 +18,14 @@
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 {
fails : s64 = 0;
main :: () -> i32 {
fails : i64 = 0;
// 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant).
print("== idle t=0 is rest for all cells ==\n");
rest_ok := true;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
p := idle_pose(0.0, col, row);
if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) {
rest_ok = false;
@@ -39,8 +39,8 @@ main :: () -> s32 {
print("== idle mid-phase deforms, bounded ==\n");
moved := false;
bounded := true;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
p := idle_pose(0.6, col, row);
if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; }
if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; }
@@ -82,7 +82,7 @@ main :: () -> s32 {
c_peak := clear_pop_scale(0.30) > 1.1;
c_collapse := true;
pc := clear_pop_scale(CLEAR_POP_RISE);
for 1..21: (i) {
for 1..21 (i) {
tt := CLEAR_POP_RISE + (1.0 - CLEAR_POP_RISE) * cast(f32) i / 20.0;
vv := clear_pop_scale(tt);
if vv > pc + 0.000001 { c_collapse = false; }

View File

@@ -10,24 +10,25 @@
// second `Frame` struct that collides with the UI `Frame`. Failure is signalled
// via a non-zero exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
main :: () -> s32 {
main :: () -> i32 {
// 800×600 with no safe inset → a 600px square grid, cell 75, centered: the
// grid origin lands at (100, 0). Integer math keeps the dump deterministic.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
print("grid origin ({},{}) cell {}\n",
cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size);
cast(i64) lay.origin.x, cast(i64) lay.origin.y, cast(i64) lay.cell_size);
fails : s64 = 0;
fails : i64 = 0;
// Every cell center must map back to its own cell.
hits : s64 = 0;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
hits : i64 = 0;
for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS (col) {
cf := lay.cell_frame(col, row);
center := Point.{ x = cf.mid_x(), y = cf.mid_y() };
if h := lay.point_to_cell(center) {
@@ -41,8 +42,8 @@ main :: () -> s32 {
// A cell's top-left corner belongs to that cell (the leading edge is
// inclusive), so corner-of-(3,5) resolves to (3,5).
corner := Point.{ x = lay.origin.x + 3.0 * lay.cell_size, y = lay.origin.y + 5.0 * lay.cell_size };
corner_col : s64 = -1;
corner_row : s64 = -1;
corner_col : i64 = -1;
corner_row : i64 = -1;
if h := lay.point_to_cell(corner) { corner_col = h.col; corner_row = h.row; }
if corner_col != 3 or corner_row != 5 { fails += 1; }
print("corner maps to ({},{})\n", corner_col, corner_row);
@@ -52,7 +53,7 @@ main :: () -> s32 {
off_left := Point.{ x = lay.origin.x - 5.0, y = lay.origin.y + 10.0 };
off_above := Point.{ x = lay.origin.x + 10.0, y = lay.origin.y - 5.0 };
off_right := Point.{ x = lay.origin.x + 8.0 * lay.cell_size + 1.0, y = lay.origin.y + 10.0 };
on_board : s64 = 0;
on_board : i64 = 0;
if h := lay.point_to_cell(off_left) { on_board += 1; print("off_left hit ({},{})\n", h.col, h.row); }
if h := lay.point_to_cell(off_above) { on_board += 1; print("off_above hit ({},{})\n", h.col, h.row); }
if h := lay.point_to_cell(off_right) { on_board += 1; print("off_right hit ({},{})\n", h.col, h.row); }

View File

@@ -25,7 +25,7 @@ RESHUFFLE_SEED :: 1337;
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -34,11 +34,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed, the turn counters reset to a fresh game, and
// the per-level goal set.
load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) -> Board {
load_board :: (rows: []string, seed: i64, move_limit: i64, target_score: i64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -51,11 +51,11 @@ load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) ->
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
main :: () -> s32 {
main :: () -> i32 {
print("== level (turn / goal state machine) ==\n");
// ── Start: a fresh seeded board reads in_progress with the default goal ──

View File

@@ -11,7 +11,7 @@ t :: #import "test.sx";
// Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid of GEM_CHARS.
char_to_gem :: (c: u8) -> Gem {
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -20,11 +20,11 @@ char_to_gem :: (c: u8) -> Gem {
// Build a scene: load an 8x8 board from `rows` (top row first, each exactly
// BOARD_COLS gem characters), detect matches, print board + matched dump, and
// assert the matched-cell count.
scene :: (name: string, rows: []string, want_count: s64) {
scene :: (name: string, rows: []string, want_count: i64) {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -38,7 +38,7 @@ scene :: (name: string, rows: []string, want_count: s64) {
t.expect(m.count() == want_count, name);
}
main :: () -> s32 {
main :: () -> i32 {
// Single horizontal 3-run (row 3, cols 2-4).
scene("horizontal-3", .[
"OGOGOGOG",

View File

@@ -22,7 +22,7 @@ SEED :: 1337;
// board can be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -32,23 +32,23 @@ char_to_gem :: (c: u8) -> Gem {
// The RNG is left unseeded — callers seed it before drawing.
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b
}
count_empties :: (b: *Board) -> s64 {
n : s64 = 0;
for 0..BOARD_CELLS: (i) { if b.cells[i] == .empty { n += 1; } }
count_empties :: (b: *Board) -> i64 {
n : i64 = 0;
for 0..BOARD_CELLS (i) { if b.cells[i] == .empty { n += 1; } }
n
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
@@ -72,7 +72,7 @@ fresh_board :: () -> Board {
b
}
main :: () -> s32 {
main :: () -> i32 {
print("== refill (seeded) ==\n");
// Pipeline, snapshotting each stage.
@@ -101,7 +101,7 @@ main :: () -> s32 {
distinct := false;
have_first := false;
first : Gem = .empty;
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if pre.cells[i] == .empty {
want := cast(Gem) v.next_range(GEM_COUNT);
if b.cells[i] != want { stream_ok = false; }
@@ -123,19 +123,19 @@ main :: () -> s32 {
// filled, then refill again. The board's RNG has advanced past the first
// fill, so the second fill draws new gems — proof it does NOT reseed per call.
holes_n := 0;
hole_idx : [BOARD_CELLS]s64 = ---;
hole_idx : [BOARD_CELLS]i64 = ---;
fill1 : [BOARD_CELLS]Gem = ---;
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if pre.cells[i] == .empty {
hole_idx[holes_n] = i;
fill1[holes_n] = b.cells[i];
holes_n += 1;
}
}
for 0..holes_n: (k) { b.cells[hole_idx[k]] = .empty; }
for 0..holes_n (k) { b.cells[hole_idx[k]] = .empty; }
refill(@b);
differs := false;
for 0..holes_n: (k) {
for 0..holes_n (k) {
if b.cells[hole_idx[k]] != fill1[k] { differs = true; }
}
t.expect(differs, "refill: a second refill of the same holes draws new gems (RNG threads, no reseed)");

View File

@@ -20,7 +20,7 @@ t :: #import "test.sx";
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -30,9 +30,9 @@ char_to_gem :: (c: u8) -> Gem {
// with the running score zeroed so the accumulation check starts from a known base.
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -42,7 +42,7 @@ load_board :: (rows: []string) -> Board {
// Score one scene: snapshot board + enumerated runs + points, then assert
// `score_round` is exact and `add_round_score` accumulates it into `board.score`.
scene :: (name: string, rows: []string, want_points: s64) {
scene :: (name: string, rows: []string, want_points: i64) {
b := load_board(rows);
runs := find_runs(@b);
@@ -58,7 +58,7 @@ scene :: (name: string, rows: []string, want_points: s64) {
concat(name, ": add_round_score accumulates into board.score"));
}
main :: () -> s32 {
main :: () -> i32 {
print("== score (base match scoring) ==\n");
// Single length-3 horizontal run (row 3, cols 2-4) -> SCORE_RUN_3 = 30.

View File

@@ -16,7 +16,7 @@ SEED :: 1337;
// Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid of GEM_CHARS.
char_to_gem :: (c: u8) -> Gem {
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
// characters).
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -38,17 +38,17 @@ load_board :: (rows: []string) -> Board {
// Whole-board equality, cell by cell — used to prove a trial swap leaves the
// board untouched.
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
cell :: (col: s64, row: s64) -> Cell {
cell :: (col: i64, row: i64) -> Cell {
Cell.{ col = col, row = row }
}
main :: () -> s32 {
main :: () -> i32 {
print("== swap & legality ==\n");
// Board whose ONLY swap-formable match is the adjacent (2,3)<->(3,3)

View File

@@ -13,32 +13,33 @@
// its trace.sx pulls in a second `Frame` that collides with the UI one. Failure
// is signalled via a non-zero exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
#import "swipe.sx";
SEED :: 1337;
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point {
cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() }
}
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> s32 {
main :: () -> i32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A
// 60px drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
D : f32 = 60.0;
fails : s64 = 0;
fails : i64 = 0;
// ── ILLEGAL swipe reverts ──────────────────────────────────────────────
// (0,0) and (1,0) are both red on the seed board, so swapping them forms no

View File

@@ -9,18 +9,19 @@
// clears it; a 10px drag does not. Failure is signalled via a non-zero exit code
// (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
#import "swipe.sx";
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point {
cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() }
}
// Print the resolved intent (locked in the golden) and report whether it matches
// the expected adjacent pair (A, B). Drives the exit code alongside the dump.
expect_swap :: (label: string, got: ?Swap, ac: s64, ar: s64, bc: s64, br: s64) -> bool {
expect_swap :: (label: string, got: ?Swap, ac: i64, ar: i64, bc: i64, br: i64) -> bool {
if s := got {
print("{}: ({},{})->({},{})\n", label, s.a.col, s.a.row, s.b.col, s.b.row);
return s.a.col == ac and s.a.row == ar and s.b.col == bc and s.b.row == br;
@@ -38,14 +39,14 @@ expect_none :: (label: string, got: ?Swap) -> bool {
true
}
main :: () -> s32 {
main :: () -> i32 {
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
print("grid origin ({},{}) cell {} threshold {}\n",
cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size,
cast(s64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION));
cast(i64) lay.origin.x, cast(i64) lay.origin.y, cast(i64) lay.cell_size,
cast(i64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION));
fails : s64 = 0;
fails : i64 = 0;
// A known interior cell; every cardinal swipe from it stays on the board.
start := cell_center(@lay, 3, 5);

144
tests/swipe_reshuffle.sx Normal file
View File

@@ -0,0 +1,144 @@
// UI deadlock-recovery golden (final-review F1): prove the RENDERED swipe-commit
// path reshuffles a deadlocked board, just like the headless turn loop's no-moves
// rule (tests/level.sx). The iOS/macOS view commits a swipe through
// `plan_and_commit` (NOT `play_turn`), so the reshuffle must live on THAT shared
// path or a stuck board stays stuck on screen. This test drives exactly what
// BoardView.handle_event does — resolve a drag with `swipe_intent`, feed the intent
// into `plan_and_commit` — on the provably deadlocked diagonal-Latin-square board
// from tests/level.sx, asserting:
// - BEFORE: no immediate match, zero legal swaps, status in_progress (stuck);
// - AFTER the UI commit path resolves: the board RESHUFFLED — `has_legal_swap`
// is true and >=1 legal swap exists, still with no immediate match — and the
// reshuffle itself spent no move and no score (turn accounting unchanged).
// Pre-fix `plan_and_commit` skipped the reshuffle, so `has_legal_swap` stayed false
// after and this FAILS; with the shared `reshuffle_if_deadlocked` wired in it PASSES.
// No rendering, no model reach-around. Links headless like tests/anim_plan.sx;
// avoids tests/test.sx (its trace.sx pulls in a second `Frame` that collides with
// the UI one). Failure is a non-zero exit code (the runner checks exit + stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_anim.sx";
#import "board_layout.sx";
#import "swipe.sx";
SEED :: 1337;
// Inverse of `gem_char`: map a board character back to its Gem so the deadlocked
// board can be written as a human-readable grid (mirrors tests/level.sx).
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first), seeded RNG, score zeroed, turn
// counters reset to a fresh game, and the per-level goal set.
load_board :: (rows: []string, seed: i64, move_limit: i64, target_score: i64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b.rng = rng_seeded(seed);
b.score = 0;
b.moves_made = 0;
b.move_limit = move_limit;
b.target_score = target_score;
b
}
cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() }
}
main :: () -> i32 {
fails : i64 = 0;
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A 60px
// drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis — the
// SAME layout/threshold tests/swipe_commit.sx drives the UI path through.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
D : f32 = 60.0;
// The provably deadlocked board from tests/level.sx: gem(col,row)=(col-row) mod
// 6, a diagonal Latin square. Equal gems lie only on slope-1 diagonals, so no
// row/column holds two of a kind within reach — no immediate match and no
// orthogonal swap can line up three.
print("== deadlocked board: UI swipe-commit path must reshuffle ==\n");
b := load_board(.[
"ROYGBPRO",
"PROYGBPR",
"BPROYGBP",
"GBPROYGB",
"YGBPROYG",
"OYGBPROY",
"ROYGBPRO",
"PROYGBPR",
], SEED, 10, 1500);
out(board_dump(@b));
pre_m := find_matches(@b);
pre_sw := legal_swaps(@b);
print("before: matches {} legal_swaps {} has_legal_swap {} status {}\n",
pre_m.count(), pre_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
if pre_m.count() != 0 { fails += 1; }
if pre_sw.len != 0 { fails += 1; }
if has_legal_swap(@b) { fails += 1; }
if level_status(@b) != .in_progress { fails += 1; }
score_before := b.score;
made_before := b.moves_made;
remain_before := b.moves_remaining();
// Drive the EXACT UI path: a rightward drag on (0,0) resolves to the (0,0)->(1,0)
// swap intent, which BoardView.handle_event feeds straight into plan_and_commit.
// On this deadlocked board every swap is illegal (no match), so the swipe itself
// commits nothing — the recovery must come from the post-settle reshuffle.
a0 := cell_center(@lay, 0, 0);
if s := swipe_intent(@lay, a0, Point.{ x = a0.x + D, y = a0.y }) {
print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row);
if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; }
mv := plan_and_commit(@b, s.a, s.b);
print("commit: legal {} rounds {} awarded {}\n", mv.legal, mv.rounds.len, mv.awarded);
// The illegal swipe spends nothing; its timeline is the bare ping-back.
if mv.legal { fails += 1; }
if mv.rounds.len != 0 { fails += 1; }
} else {
print("intent none\n");
fails += 1;
}
post_m := find_matches(@b);
post_sw := legal_swaps(@b);
print("after: matches {} legal_swaps {} has_legal_swap {} status {}\n",
post_m.count(), post_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
print("after: score {} moves_made {} moves_remaining {}\n",
b.score, b.moves_made, b.moves_remaining());
out(board_dump(@b));
// The fix: the UI commit path reshuffled the deadlock away. has_legal_swap flips
// false -> true; >=1 legal swap exists; still no immediate match.
if !has_legal_swap(@b) { fails += 1; }
if post_sw.len <= 0 { fails += 1; }
if post_m.count() != 0 { fails += 1; }
// The reshuffle is NOT a move: score, moves spent, and budget are untouched.
if b.score != score_before { fails += 1; }
if b.moves_made != made_before { fails += 1; }
if b.moves_remaining() != remain_before { fails += 1; }
if level_status(@b) != .in_progress { fails += 1; }
if fails == 0 {
print("ok: UI swipe-commit path reshuffles a deadlocked board\n");
return 0;
}
print("FAIL: {} UI-reshuffle checks failed\n", fails);
return 1;
}

View File

@@ -5,7 +5,7 @@
// terminates the process NON-ZERO (exit 1) via process.exit, so a broken
// assertion fails `tools/run_tests.sh` and the build gate.
#import "modules/std.sx";
proc :: #import "modules/process.sx";
proc :: #import "modules/std/process.sx";
expect :: (cond: bool, msg: string, loc: Source_Location = #caller_location) {
if !cond {

View File

@@ -28,7 +28,7 @@ LIMIT :: 5;
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
@@ -37,11 +37,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed, and the turn counters reset to a fresh game
// (no moves made, the given move budget).
load_board :: (rows: []string, seed: s64, move_limit: s64) -> Board {
load_board :: (rows: []string, seed: i64, move_limit: i64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
@@ -53,14 +53,14 @@ load_board :: (rows: []string, seed: s64, move_limit: s64) -> Board {
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
// One flag scene: snapshot the board, then count its single round's special
// runs and assert the tallies (and the boolean flags derived from them) are
// exactly the documented values. No RNG, no clear — pure detection.
flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64) {
flag_scene :: (name: string, rows: []string, want_len4: i64, want_len5_plus: i64) {
print("== {} ==\n", name);
b := load_board(rows, 0, LIMIT);
out(board_dump(@b));
@@ -71,7 +71,7 @@ flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64
t.expect(sp.len5_plus == want_len5_plus, concat(name, ": len5_plus count exact"));
}
main :: () -> s32 {
main :: () -> i32 {
print("== turn (accounting + special-match flagging) ==\n");
// ── Special-match flagging (single round, no RNG) ──────────────────────

View File

@@ -20,7 +20,7 @@
// never hits this — its loops run over 64 board cells, not millions of pixels.
#import "modules/std.sx";
#import "modules/math";
#import "modules/stb.sx";
#import "vendors/stb_image/stb_image.sx";
SRC_PATH :: "/Users/agra/Downloads/m3te_particle.png";
OUT_PATH :: "assets/fx/particle.png";
@@ -33,14 +33,14 @@ GRAY_TOL :: 24; // max channel spread still considered neutral gray
LUM_MARGIN :: 4; // lum headroom above the light checker shade
is_gray :: (r: u8, g: u8, b: u8) -> bool {
hi := max(max(cast(s64) r, cast(s64) g), cast(s64) b);
lo := min(min(cast(s64) r, cast(s64) g), cast(s64) b);
hi := max(max(cast(i64) r, cast(i64) g), cast(i64) b);
lo := min(min(cast(i64) r, cast(i64) g), cast(i64) b);
hi - lo <= GRAY_TOL
}
// Mark pixel `i` as removed background and queue it, if it is unvisited checker
// (near-neutral gray no brighter than the light checker shade + margin).
fd_seed :: (i: s64, bg: [*]u8, lum: [*]s64, src: [*]u8, stack: [*]s64, sp: *s64, lim: s64) {
fd_seed :: (i: i64, bg: [*]u8, lum: [*]i64, src: [*]u8, stack: [*]i64, sp: *i64, lim: i64) {
if bg[i] != 0 { return; }
p := i * 4;
if lum[i] <= lim and is_gray(src[p], src[p+1], src[p+2]) {
@@ -50,35 +50,35 @@ fd_seed :: (i: s64, bg: [*]u8, lum: [*]s64, src: [*]u8, stack: [*]s64, sp: *s64,
}
}
main :: () -> s32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
main :: () -> i32 {
w : i32 = 0;
h : i32 = 0;
ch : i32 = 0;
src : [*]u8 = xx stbi_load(SRC_PATH, @w, @h, @ch, 4);
if xx src == 0 {
print("FATAL: could not load {}\n", SRC_PATH);
return 1;
}
W := cast(s64) w;
H := cast(s64) h;
W := cast(i64) w;
H := cast(i64) h;
N := W * H;
print("loaded {}x{} ({} src channels)\n", w, h, ch);
// Hoisted working locals (see codegen note above).
y : s64 = 0;
x : s64 = 0;
i : s64 = 0;
p : s64 = 0;
r : s64 = 0;
g : s64 = 0;
b : s64 = 0;
l : s64 = 0;
y : i64 = 0;
x : i64 = 0;
i : i64 = 0;
p : i64 = 0;
r : i64 = 0;
g : i64 = 0;
b : i64 = 0;
l : i64 = 0;
// Per-pixel luminance, plus the checker shades read off the border ring
// (the border is pure checker — the glow never reaches the corners).
lum : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
c_lo : s64 = 255;
c_hi : s64 = 0;
lum : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
c_lo : i64 = 255;
c_hi : i64 = 0;
y = 0;
while y < H {
x = 0;
@@ -102,10 +102,10 @@ main :: () -> s32 {
// 8-connected flood fill of the edge-connected checker, seeded from every
// border pixel. `bg[i]==1` marks a removed (transparent) background pixel.
bg : [*]u8 = xx context.allocator.alloc(N);
bg : [*]u8 = xx context.allocator.alloc_bytes(N);
memset(xx bg, 0, N);
stack : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
sp : s64 = 0;
stack : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
sp : i64 = 0;
checker_lim := c_hi + LUM_MARGIN;
x = 0;
@@ -121,12 +121,12 @@ main :: () -> s32 {
y += 1;
}
cx : s64 = 0;
cy : s64 = 0;
dx : s64 = 0;
dy : s64 = 0;
nx : s64 = 0;
ny : s64 = 0;
cx : i64 = 0;
cy : i64 = 0;
dx : i64 = 0;
dy : i64 = 0;
nx : i64 = 0;
ny : i64 = 0;
while sp > 0 {
sp -= 1;
i = stack[sp];
@@ -153,9 +153,9 @@ main :: () -> s32 {
// checker shade up to pure white, giving the glow its smooth falloff.
denom := cast(f32) (255 - c_hi);
if denom < 1.0 { denom = 1.0; }
alpha : [*]f32 = xx context.allocator.alloc(N * size_of(f32));
kept : s64 = 0;
n_bg : s64 = 0;
alpha : [*]f32 = xx context.allocator.alloc_bytes(N * size_of(f32));
kept : i64 = 0;
n_bg : i64 = 0;
a : f32 = 0.0;
i = 0;
while i < N {
@@ -174,30 +174,30 @@ main :: () -> s32 {
// Area-averaged downscale to OUT_DIM. RGB stays white; only the averaged
// alpha carries the sprite, so no premultiply is needed (white*cov == white).
out_px : [*]u8 = xx context.allocator.alloc(OUT_DIM * OUT_DIM * 4);
out_px : [*]u8 = xx context.allocator.alloc_bytes(OUT_DIM * OUT_DIM * 4);
sxf := cast(f32) W / cast(f32) OUT_DIM;
syf := cast(f32) H / cast(f32) OUT_DIM;
max_a : f32 = 0.0;
ty : s64 = 0;
tx : s64 = 0;
x0 : s64 = 0;
x1 : s64 = 0;
y0 : s64 = 0;
y1 : s64 = 0;
ty : i64 = 0;
tx : i64 = 0;
x0 : i64 = 0;
x1 : i64 = 0;
y0 : i64 = 0;
y1 : i64 = 0;
sum : f32 = 0.0;
cnt : s64 = 0;
sy : s64 = 0;
sx : s64 = 0;
cnt : i64 = 0;
sy : i64 = 0;
sx : i64 = 0;
av : f32 = 0.0;
o : s64 = 0;
o : i64 = 0;
ty = 0;
while ty < OUT_DIM {
tx = 0;
while tx < OUT_DIM {
x0 = cast(s64) (cast(f32) tx * sxf);
x1 = cast(s64) (cast(f32) (tx + 1) * sxf);
y0 = cast(s64) (cast(f32) ty * syf);
y1 = cast(s64) (cast(f32) (ty + 1) * syf);
x0 = cast(i64) (cast(f32) tx * sxf);
x1 = cast(i64) (cast(f32) (tx + 1) * sxf);
y0 = cast(i64) (cast(f32) ty * syf);
y1 = cast(i64) (cast(f32) (ty + 1) * syf);
if x1 <= x0 { x1 = x0 + 1; }
if y1 <= y0 { y1 = y0 + 1; }
sum = 0.0;

View File

@@ -1,55 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef __ANDROID__
#include <android/asset_manager.h>
// Caller-installed AAssetManager pointer. Chess's android_main extracts
// it from `app->activity->assetManager` (via sx-side platform module's
// `g_android_asset_manager` global) and feeds it here once at startup.
// Until the setter has been called, Android falls through to fopen —
// gives a predictable "file not found" rather than a NULL-deref.
static AAssetManager* g_aam = NULL;
void sx_android_set_asset_manager(void* m) {
g_aam = (AAssetManager*)m;
}
#endif
unsigned char* read_file_bytes(const char* path, int* out_size) {
#ifdef __ANDROID__
if (g_aam != NULL) {
// AAssetManager paths are relative to the APK's `assets/`
// directory. Strip a leading "assets/" so callers can use the
// same paths across iOS/macOS/Android (those platforms read
// assets via `assets/...` rooted in the bundle or CWD).
const char* lookup = path;
if (strncmp(path, "assets/", 7) == 0) {
lookup = path + 7;
}
AAsset* a = AAssetManager_open(g_aam, lookup, AASSET_MODE_BUFFER);
if (a != NULL) {
off_t n = AAsset_getLength(a);
*out_size = (int)n;
unsigned char* buf = (unsigned char*)malloc((size_t)n);
if (buf != NULL) {
memcpy(buf, AAsset_getBuffer(a), (size_t)n);
}
AAsset_close(a);
return buf;
}
// Falls through to fopen — useful when assets land in the data
// dir via extraction or app updates.
}
#endif
FILE* f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
*out_size = (int)ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char* buf = (unsigned char*)malloc(*out_size);
fread(buf, 1, *out_size, f);
fclose(f);
return buf;
}

View File

@@ -1,13 +0,0 @@
#ifndef FILE_UTILS_H
#define FILE_UTILS_H
unsigned char* read_file_bytes(const char* path, int* out_size);
#ifdef __ANDROID__
// Install the AAssetManager that `read_file_bytes` consults for paths
// rooted inside the APK. Caller is responsible for passing the manager
// from `ANativeActivity->assetManager` before any read_file_bytes call.
void sx_android_set_asset_manager(void* m);
#endif
#endif

View File

@@ -1,19 +0,0 @@
zlib License
(C) Copyright 2024-2025 Jimmy Lefevre
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define KB_TEXT_SHAPE_IMPLEMENTATION
#include "kb/kb_text_shape.h"

View File

@@ -1,15 +0,0 @@
// Minimal API declarations for SX import.
// Only the functions/types we actually use — avoids parsing the full 30k-line header.
typedef struct kbts_shape_context kbts_shape_context;
typedef struct kbts_font kbts_font;
kbts_shape_context *kbts_CreateShapeContext(void *Allocator, void *AllocatorData);
void kbts_DestroyShapeContext(kbts_shape_context *Context);
kbts_font *kbts_ShapePushFontFromMemory(kbts_shape_context *Context, void *Memory, int Size, int FontIndex);
void kbts_GetFontInfo2(kbts_font *Font, void *Info);
void kbts_ShapeBegin(kbts_shape_context *Context, unsigned int ParagraphDirection, unsigned int Language);
void kbts_ShapeUtf8(kbts_shape_context *Context, const char *Utf8, int Length, unsigned int UserIdGenerationMode);
void kbts_ShapeEnd(kbts_shape_context *Context);
int kbts_ShapeRun(kbts_shape_context *Context, void *Run);
int kbts_GlyphIteratorNext(void *It, void **Glyph);

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"