# Crash BASIC 1.36.4 Release Notes

*~written by Crash-tan~*

## THE "THE DOCS HAD HOLES AND THE GRAMMAR HAD A GRUDGE" PATCH

A grab bag of paper cuts, one quietly missing feature, and one bug
that keeps coming back like a bad penny. The file-I/O docs were
missing entries for keywords the runtime has supported forever. The
grammar's single-line `IF ... THEN <stmt>` was a hand-curated whitelist
that quietly refused most of the language. BASIC had no way to ask
"what files are here?" — every game that wanted to enumerate save
slots was hardcoding lists or doing existence-check loops over guessed
names. And the basemap's per-tile `frameTime` field hadn't actually
been reaching the renderer — eval was silently inserting a `SPEED 60`
override on every `CREATE MAP` that omitted the clause, so every
animation ran at 16ms regardless of what the basemap declared.
All fixed.

---

## File-I/O docs caught up to reality

`docs/api-docs.json` had an entry for `OPEN DATA` — the form that opens
an in-program `DATA` block — but no entry for plain `OPEN`, the form
that opens an actual file. Same for several friends: the API JSON shipped
without entries for the keywords half the engine actually understood.

Filled the gaps:

| New entry | What it does |
|---|---|
| `OPEN` | The file-stream form: `OPEN path$ FOR INPUT \| OUTPUT \| APPEND AS #n`. Documents the multiplayer policy too — writes are server-side only, reads are allowed everywhere. |
| `LINE INPUT` | `LINE INPUT #n, var$` — reads one line verbatim, no auto-parsing. |
| `PRINT #` | The file form of `PRINT`. Kept separate from console `PRINT` because the modifiers and semantics are different (no `WORDS`, no `RATE`, plain text only). |
| `FILEEXISTS` | Filesystem check. Called out as **distinct from `EXISTS`**, which checks the in-memory resource manager (`LOADIMG` / `LOADWAV` names). FILEEXISTS is for raw paths. |
| `LOC` | Current position in a handle. DATA → 0-based value index. File → 0-based line number. Closed handle → `-1`. |
| `LOF` | Total size. DATA → value count. File → line count. Closed handle → `-1`. |
| `ISOPEN` | `1` if the handle is open, `0` otherwise. The defensive close you've been wanting. |

Two existing entries also got updated. `READ` previously claimed it
only read from DATA blocks; in fact it also reads file streams — one
line per variable, auto-parsed into INTEGER / FLOAT / STRING — and the
docs now say so. `EOF` had the same DATA-only-language problem and
now correctly covers both, including the convention that a closed or
invalid handle returns `TRUE` (so a `WHILE NOT EOF(n)` against a stray
handle terminates immediately instead of spinning).

Every example in the new and updated entries is pinned by a test in
`ast_builder.rs` that parses the literal source through the real parser.
If a future edit breaks a doc example, the test fails. 19 tests, one per
example.

---

## Single-line IF stopped being a whitelist

The grammar had this:

```
inline_statement = { exit_stmt | return_stmt | boom_stmt | assignment
                   | call_basic_stmt | io_stmt | graphics_stmt | audio_stmt }
```

That's the rule that says what can sit after `THEN` in a single-line `IF
cond THEN <stmt>`. It's a hand-curated set of eight categories — which
means anything outside those eight rejects. So:

```basic
IF ISOPEN(1) THEN CLOSE #1         ' rejected — CLOSE isn't in the whitelist
IF needsBuf THEN DIM buf(100)      ' rejected — DIM isn't in the whitelist
IF dead THEN SET STATE "gameover"  ' rejected — SET STATE isn't either
IF tick THEN SYNC                  ' SYNC also rejected
```

…none of which is a real syntactic problem. They're just statements the
whitelist forgot.

Worse, the curation wasn't even principled: `graphics_stmt` and
`audio_stmt` were *in* the whitelist even though those categories include
block forms like `BEGIN MEMORYBUFFER ... END MEMORYBUFFER`. The block
forms are filtered out by their distinct leading keywords; pest just
falls through to the inline alternatives. Which means there was no
structural reason to exclude `data_stmt`, `net_stmt`, `runtime_stmt`,
`dim_stmt`, etc. — they were just oversights.

`inline_statement` is now a category mirror of the top-level `statement`
rule, omitting only the categories that *only* contain block forms
(namespace, type definition, enum, function/sub definition, the
IF/WHILE/FOR/DO control-block forms, standalone comment lines). Every
other category is in. Adding a new keyword to file I/O, networking, or
the runtime category makes it single-line-IF-eligible automatically; no
more grammar surgery per keyword.

The Rust-side AST builder had a parallel whitelist for the same reason.
That's collapsed too: `build_inline_statement` now delegates to the
central `build_statement_from_rule` dispatcher, so the inline-eligible
set is driven by the grammar alone.

### What this enables

```basic
IF ISOPEN(1) THEN CLOSE #1                ' defensive close
IF needsBuf THEN DIM buf(100) AS INTEGER  ' conditional dim
IF debug THEN CONST LOG_LEVEL = 3         ' conditional const
IF reorder THEN SWAP a, b                 ' conditional swap
IF hit THEN PUSH hits, target             ' conditional push
IF dead THEN SET STATE "gameover"         ' conditional state change
IF tick THEN SYNC                         ' conditional sync
IF dirty THEN SAVETEXT "out.txt", state$  ' conditional save
IF have THEN OPEN "s.dat" FOR INPUT AS #1 ELSE OPEN "s.dat" FOR OUTPUT AS #1
```

All of these previously had to be rewritten as multi-line `IF ... END IF`.
Now they don't.

12 new tests fence the broader coverage — one per newly-eligible
category, plus an ELSE-branch test confirming the broadening applies to
both halves of single-line IF. If anyone ever tries to narrow
`inline_statement` back to a whitelist, those tests fail loudly.

---

## New: `FILES([path$])` — discover what's saved without guessing

The thing every game with save slots wanted and nobody had: ask BASIC for
a list of files. Solo, host server, player-server — same call, same answer.

```basic
FOR EACH f IN FILES()
  PRINT f
NEXT

' Save slots specifically
saves = FILES("saves")
PRINT "You have " + STR$(UBOUND(saves) + 1) + " saved games"

' Round-trip — write something, it shows up next time
SAVETEXT "slot01.dat", SERIALIZE$(player)
allFiles = FILES()  ' "slot01.dat" is in there now
```

Returns a 1D array of filenames (basenames, no paths). Subdirectories
are skipped — pass the subdir as `path$` to enumerate inside.
Alphabetically sorted by byte order, so the result is deterministic
across platforms and across replays. Dotfiles (`.DS_Store`, editor swap
files) are skipped because no game ever wanted those.

### Unified single- and multi-player

This is the part that made the work actually big.

`FILES()` returns the **union of cart-bundled assets and user-saved
files** — both in solo play and on a server. A file written via
`SAVETEXT` shows up in a subsequent `FILES()` call regardless of where
it landed: a sandboxed directory in single-player, the local CrashPlayer
cache in WASM, the SQLite store on a CrashNet server. Same call, same
shape, same answer. Per-player saves on a server are scoped to that
player — other players' saves stay invisible.

This required threading `list_files` through every `IoBus`
implementation (nine of them), adding `host_list` / `player_list` to the
DB backend, an extra `VfsList` / `VfsFileList` round-trip on the WASM
wire (proto + JS codec on both ends), and matching sandbox semantics
across all of them. The pay-off is one rule for BASIC: a save you wrote
in solo play that you later host as multiplayer reports the same when
queried, period.

### Sandbox is fatal, not silent

`FILES("../etc")` doesn't return `[]` — it halts the interpreter with a
runtime error pointing at the offending path. Path-construction bugs
that would otherwise silently scan the wrong directory now fail loud,
matching how `OPEN FOR OUTPUT` / `SAVETEXT` report policy violations.

---

## Fix: the basemap's per-tile `frameTime` is honored again

Affects every platform. Affects every game with animated tiles. Third
time this has surfaced, and the regression test added with this fix is
the fence that should stop a fourth.

### Severity, plainly

The `SPEED` clause itself was never broken. Write `SPEED 5` on your
`CREATE MAP` and the renderer correctly produces a 200ms global
cadence — exactly the override the docs describe.

The bug is in what happened **when you didn't write SPEED at all**.
Eval silently substituted `speed_fps = 60.0` on the wire for three
paths (file-loaded, named, inline) and `UPDATE MAP` hard-coded 60.0
on every refresh. The renderer's override path gates on
`speed_fps > 0.0`, so any non-zero value — including the one eval
invented — flipped the override on and computed `1000/60 ≈ 16ms` per
frame.

Result: **the basemap format's per-animation `frameTime` was never
respected.** The Map Editor wrote it, `graphics_bus::parse_animations`
parsed it (default 200ms when missing), the named-tilemap eval cloned
it onto the wire as `TilemapAnimation.frame_time_ms` — all
faithfully. Then the renderer ignored it on every CREATE MAP, because
eval had also placed a fabricated `speed_fps` next to it that took
precedence. The per-tile cadence the basemap declared was reachable
only by writing `SPEED 0` — which nobody does because the natural
reading is "don't animate."

So: SPEED-the-override worked. The basemap's `frameTime` field —
which is the entire reason that field exists in the schema — did
not, because eval kept the override path on even when the user hadn't
asked for an override.

### Practical consequences

- **Mixed-cadence animations on the same tilemap were impossible.**
  Slow lava + fast torches + occasional flicker couldn't coexist: the
  override won the whole map.
- **Map Editor output was systematically wrong.** Tiles authored with
  the default `frameTime: 200` ran at ~16ms in every shipped game,
  every platform.
- **Authored per-tile timing was unreachable.** Setting `frameTime:
  500` for a slow flame and `frameTime: 100` for a fast spark on the
  same map did nothing observable from BASIC.

### What `CREATE MAP` does with timing now

- **No `SPEED` clause** — per-animation `frameTime` from the basemap
  wins. A tile with `frameTime: 200` animates at 5 frames/sec. A tile
  with `frameTime: 1000` animates at 1 frame/sec. They can differ on
  the same map. This is what the basemap format was designed to
  express, and what omitted-SPEED was always supposed to mean.
- **Explicit `SPEED N`** — global override, unchanged. Every animation
  on this tilemap runs at N frames/sec, ignoring per-tile timing.
  `SPEED 4` for a slow water cycle; `SPEED 60` for buzzing
  electricity. The override worked before this patch too — that part
  was never broken.

`UPDATE MAP` no longer reintroduces a fabricated `SPEED 60` on every
refresh either.

### Why it kept resurfacing

Every clean-up pass through this code has "helpfully" reaffirmed the
60.0 default in at least one of the four sites. `unwrap_or(60.0)`
reads like documentation when you're scanning for missing defaults
and you're not staring at a strobing animation. The renderer's 0.0
sentinel — the actual contract — lived three layers away in
`commands.rs`, with no comment on the eval side pointing at it.

The fix isn't just changing the numbers; it's leaving a comment at
each eval site explaining what 0.0 means and *why* 60.0 looks wrong
even when nothing's currently broken.

### What changed

- `eval/graphics.rs` — three `unwrap_or(60.0)` (file, named, inline)
  and one hard-coded `60.0` in `UPDATE MAP` all replaced with `0.0`,
  with comments pointing back at the renderer's sentinel so the next
  clean-up doesn't "fix" them by reintroducing the bug.
- `docs/api-docs.json` — the CREATE MAP entry no longer says "default
  60." It says the basemap's `frameTime` is honored when SPEED is
  omitted, and `SPEED N` is an explicit global override.
- Four regression tests in `eval/graphics.rs::tests` pin the
  contract: omitted SPEED ships 0.0, explicit `SPEED N` ships N.0,
  inline form behaves the same, and `UPDATE MAP` after a no-SPEED
  `CREATE MAP` doesn't reintroduce 60.0 on refresh. If the bug
  surfaces a fourth time, *these* fail instead of you noticing during
  playtest.

### Existing programs

- If your basemap had `frameTime` values (Map Editor's default 200ms
  or whatever you authored), tile animations are now **slower and
  correct** — whatever you saw before was wrong, full stop. Mixed
  cadences on the same map work now too.
- If you explicitly wrote `SPEED N` in your `CREATE MAP`, behavior
  is unchanged.
- If you depended on the broken 16ms cadence, add `SPEED 60` to your
  `CREATE MAP` to opt back into the override at the explicit rate
  you want.

### Also: the animation cap is now 500, not 8

While the animation pipeline was open, the per-tilemap cap on number
of animations got a real audit and turned out to be silently wrong.
The docs said 8. The code said 8 in three places. The actual GPU
uniform buffer was sized for **6**. Animations 7 and 8 wrote nothing
into the shader's reserved slots and then read garbage from memory
past the uniform's end — undefined behavior, different per WebGPU
implementation, never noticed because nobody had hit it.

The 8 wasn't a hardware limit. The new cap is **500 animations** per
tilemap — set against wgpu's downlevel `max_uniform_buffer_binding_size`
of 16 KB. The tilemap uniform now occupies 16,096 bytes, leaving 288
bytes of margin. Shader-loop early-exit means a 12-animation map
still pays for 12 comparisons per fragment, not 500.

Frames per animation is still 5. That cap is structural to the vec4
packing format; bumping it would change the shader's slot layout. For
1.36.4 it stays.

For practical purposes "500 animations per tilemap" means "more
animations than your tileset has unique tiles to trigger them" — so
the practical ceiling is now whatever your tileset can address. If
you actually run into the cap, the design conversation to have is
probably about your tileset, not the cap.

The new size is driven by a single const
(`MAX_TILE_ANIMATIONS_PER_TILEMAP` in `crashgfx-protocol`). The
shader's `array<vec4<f32>, N>` size, the Rust-side uniform buffer
`[f32; …]` size, the per-frame packing loop's `.take(N)`, the
shader's `apply_animation` loop bound, the wgpu byte offsets — all of
them derive from that one number. Bumping the cap (or migrating to a
storage buffer later for bigger budgets) means changing one place.
The five-site drift that hid the original 8-vs-6 bug is gone.

---

## Not in this patch

`comment_stmt` is still excluded from inline_statement — putting `' a
comment` after `THEN` is ambiguous with the trailing-comment EOL syntax,
and disambiguating is its own can of worms. If you want a comment in the
THEN branch, multi-line IF still works fine.

`FILES()` returns regular files only. Subdirectories are skipped silently.
A separate `DIRS()` (or a marker in the FILES output) hasn't been needed
yet — when it is, it'll be additive.

---

## Compatibility notes

- **Strictly additive on the grammar side.** Anything that parsed in
  1.36.3 still parses in 1.36.4. The only change is what *also* parses
  now.
- **No language semantics changed.** A statement that's now legal after
  `THEN` behaves identically to the same statement on its own line.
- **No new keywords.** All the keywords involved already existed; this
  release just lets you put them where you'd expect.
- **Docs-only changes are forward-compatible.** Older versions of the
  api-docs.json reader (IDE autocomplete, etc.) don't care about the new
  entries — they're just extra rows in the keyword array.

---

*If the parser accepts it, the docs should describe it. If the language
has a one-liner form, every reasonable statement should fit it. If
BASIC just saved a file, BASIC should be able to find it again. And if
the basemap says 200ms, the lava better not be a strobe light. All
true now.*

*— Crash-tan*
