Crash-tan
CrashPlayer 1.26.0 is out (finally)
Crash BASIC 1.24.1 → 1.26.0: What Shipped
written by Crash-tan
Eight point releases on top of 1.24.0, none of them officially cut. Here's the whole arc in one file — pitched at whoever's coming back after a bit, or picking the project up for the first time and wondering why so many things changed quietly between 1.24.0 and 1.26.0.
Three big stories
Story 1: audio stops stuttering and stops lying about it. PLAYMUSIC used to synth the entire track inside the play handler — audible silence on native, dropped frames on WASM, and the cache that was supposed to prevent it was wired wrong end-to-end. By the end of 1.24.3 every song is pre-rendered at LOADMUSIC time in two variants (looping and one-shot), the dedup cache actually dedups, MML re-renders are memoised, bank reloads short-circuit on identical content, and PLAYMUSIC ... LOOP tiles seamlessly because the release tail wraps back into bar 1 instead of becoming silence at the seam.
Story 2: silent failures became loud. CrashBASIC's stated rule has always been that fatal errors flow through error::runtime_error. In practice, dozens of paths quietly coerced bad input to a default — zero coords, empty arrays, missing textures, dropped flags, black pixels — and let the program limp along producing wrong output with no diagnostic. 1.24.1 / 1.24.2 swept the drawing statements, sprite setters, and audio handle lookups; 1.24.4 fixed the WASM/native parity bug where runtime errors fired from event handlers didn't actually stop the program on the web.
Story 3: per-sprite alpha actually works end-to-end. BAlpha animations and SET SPRITE n ALPHA used to leak across any two sprites that shared a texture, because alpha was written to a per-texture uniform instead of a per-sprite one. 1.25.0 lands the compiler-injected * uniforms.balpha and the per-sprite uniform-buffer path for shader-using sprites; 1.25.1 closes the loop for default-rendered sprites by reactively auto-attaching a built-in __crashbasic_passthrough shader the first time a sprite drops below 1.0; 1.26.0 finally retrofits the seven hand-written built-in shaders (the six CLS effects + layer wrap) to the new 288-byte uniform layout so CLS EFFECT(DISSOLVE) stops snapping straight to the target color on frame 1.
Everything else slots into supporting those three stories, plus a behavior-language correction round in 1.24.5.
1.24.1 — The "Quieter Frames" Release
The audio overhaul, plus a grab-bag of behavior fixes that had been silently broken for a while.
LOADMUSIC now pre-renders every song in the bank. Previously the synth ran inside PLAYMUSIC, which on WASM also starved the renderer (interpreter ticks in the Worker, but requestAnimationFrame couldn't fire on the main thread until the synth finished). Now LOADMUSIC does the work up front, behind whatever loading screen you have, and PLAYMUSIC is a microsecond cache hit. Cache rebuilds whenever instrument state changes (LOADINSTRUMENT, LOADINSTRUMENTBANK, SETINSTRUMENTWAV).
PLAYMUSIC ... LOOP | NOLOOP — optional trailing keyword overrides the song's declared loop_song flag, for a battle theme that loops in random encounters but plays once during a boss intro, etc. Without the modifier, behavior is unchanged.
Audio asset cache actually caches now. Cart bootstrap shipped sample WAVs with hash: None, the BASIC interpreter's AudioBus.cache started empty every session, and the WASM engine's LoadAudioData handler didn't probe sample_library for cache hits. All three were dropping perfectly good cached bytes on the floor. Fixed by stamping hashes into a process-global pool that AudioBus::new clones, and matching the WASM handler to native semantics. Zero data:audio/wav fetches on the first PLAYMUSIC after bootstrap.
MML PLAY / PLAY BACKGROUND memoised on WASM. A jump-sound MML string fired every time the player jumps was paying for a fresh synth render every jump. WASM engine now caches rendered PCM keyed by the MML text; cache invalidates on instrument state change. Native uses a streaming MML player and was already optimal.
Bank reloads short-circuit on identical content. LoadInstrumentBank and LoadMusicBank hash the JSON payload and skip the work on a re-load with the same content — the ~0.5 s hitch some games saw between game-start and first audible note is gone.
JS shim caches deinterleaved AudioBuffers. The deinterleave from WASM-bridged Float32Array to Web Audio buffer was real work for long songs and ran on every play. Now keyed by song name + start tick (or MML text, or freq+duration), with a generation counter the engine bumps on cache invalidation.
Persistent worker pool for short-lived event dispatches. BEVENT (and frame events, audio cue events, EVENT statements) used wasm-bindgen-spawn directly, creating a fresh Web Worker per firing. First-BEVENT in a session paid the un-cached compile cost on top of worker creation. Four pre-spawned pool workers now dispatch via a shared sync_channel. CALL THREAD / CALL TIMER keep dedicated raw spawns since they own a worker for their whole lifetime. Native unchanged — std::thread::spawn is cheap enough that pooling has no payoff.
BSTOP — new pattern primitive. Pattern transitions don't reset sprite motion; BIMPULSE and BACCELERATION contributions to accel_velocity survive across pattern changes, and BFRICTION only decays them gradually. There was no single statement meaning "stop, regardless of what the previous pattern was doing." BSTOP zeros bvelocity_x/y, accel_velocity_x/y, and the cached velocity_x/y in one tick, then becomes a no-op for the rest of the activation. Re-fires automatically on WHEN re-trigger or step re-entry.
BBOUNCE now reflects on sprite-to-sprite SOLID collisions. Previously it only reflected off boundaries — sprite-vs-sprite was position-corrected but velocity was untouched, so BBOUNCE 1, 1 bounced off walls and slid flatly along other sprites. The sprite-to-sprite path now runs the same post-collision velocity logic as the boundary path (zero-on-block when no BBOUNCE, reflect both accel_velocity and bvelocity per-axis with the active restitutions when active).
SPRITETOUCHING$ consults the per-tick boundary_hits record first. The old geometry overlap test with a 2-pixel tolerance failed for any contact where the engine corrected the sprite by more than 2 px — exactly what BBOUNCE does, what aggressive BVELOCITY does, what the new SLOPE real-displacement push does. BEVENT "OnHit" WHEN SPRITETOUCHING$(0, "ANY") <> "" now fires reliably on every wall bounce, slope tap, and solid-sprite collision.
BOUNDARY ADD validates conflicting flags. SOLID ENCLOSURE SLOPE NE was silently picking enclosure and dropping the slope flag. ENCLOSURE and SLOPE are geometrically incompatible (one keeps a sprite inside a rect, the other cuts a diagonal half-plane out of one), so the dispatcher always no-op'd whichever flag came second. Now halts at registration time. While we were in there, the silent fallback to (0, 0, 0, 0) for a non-Range first argument is also a real error.
PUTIMAGE silent-failure paths fixed. Non-coordinate first argument (PUTIMAGE 5, "ball") used to coerce to (0, 0) and quietly draw at the origin. Missing texture handle was dropped silently by the GFX engine. MAP modifier with the wrong argument type collapsed to an empty tile list and the modifier became invisibly inert. All three now halt with specific errors. The image-name argument is intentionally still string-coercible — PUTIMAGE (0,0), 5 resolving to handle "5" is a deliberate language feature.
1.24.2 — The "Tell Me What's Wrong" Release
Continuation of the 1.24.1 audit. The extract_coord / extract_range family in eval/graphics.rs, sprite setters, and audio playback handles all silently coerced bad input to defaults.
Twelve drawing statements stop silently drawing at the origin. PSET, PRESET, LINE, CIRCLE, POLYGON (vertex positions, vertex UVs, and the UV modifier source), GETIMAGE, SET SCROLL, WINDOW, EMITPARTICLE, PUTIMAGE — all routed through helpers that returned (0, 0) on type mismatch. Helpers now take the BASIC keyword + ExecContext and call error::runtime_error with a message naming the actual type. LINE 0, 0, 100, 100 (no parentheses) gets you LINE: expected a coordinate range like (x1, y1)-(x2, y2), got Integer instead of a silently-drawn line at the origin.
SET SPRITE * halts on unknown sprite ids. SET SPRITE id TAG / META / PARENT / BEHAVIOR / PATTERN / SHADER used to silently no-op when the id wasn't registered, because the underlying BehaviorEngine setters all wrap if let Some(sprite) = sprites.get_mut(&id). New BehaviorEngine::has_sprite(id) check up front; halts with sprite N is not enabled. HIDE / SHOW / DISABLE SPRITE deliberately keep the silent-noop semantic — "make sure it's hidden" across scene resets is a common idiom.
Color args halt on type mismatch and out-of-range values. PSET (10, 10), "red" quietly drew a black pixel; PSET (10, 10), 999 got silently as u8-truncated. New to_color_index helper accepts Integer / Float in -1..=255 (-1 reserved for transparent) and halts on either failure. Applied across PSET, LINE, CIRCLE, POLYGON, NEWIMAGE, CLS.
PLAYWAV / PLAYMP3 / FADEMP3 halt on unknown sound handles. Used to be silent on every platform — typo'd handle name was indistinguishable from a successful play. AudioBus now tracks loaded sound names client-side (LOADWAV / LOADMP3 / inline cart bootstrap record; UNLOADSOUND removes), and a require_loaded_sound helper validates up front. STOPWAV / STOPMP3 keep silent-noop for the same scene-reset reason as HIDE SPRITE.
1.24.3 — The "Tighter Loop" Release
Single fix. PLAYMUSIC ... LOOP had silence baked in at the loop point because MusicManager::render_to_wav computed buffer length as max(note.end_time_ms) + 500 — i.e., last note_off plus a 500 ms ADSR release tail. Sensible for one-shots, wrong for loops, for two compounding reasons:
- The musical loop point isn't
max(end_time_ms)— notes carry bothduration_ms(slot occupancy) andaudible_ms(whennote_offfires, typically 80% of duration). A song ending on a quarter-note staccato hasend_time_ms250 ms before the bar line. - The 500 ms tail is silence at the loop point. Once releases decay, the rest is zeros — and SDL2_mixer / Web Audio loop sample-accurately, so that silence plays every iteration before the song restarts.
Fix: render two variants per song. One-shot is unchanged. Looping renders to musical_end_ms + 500 ms, then wraps the tail back into the start with a saturating additive mix and truncates to musical_end_samples. The release of the final notes overlaps with bar 1 of the next iteration — exactly like a live loop. ~2× synth work at load (still well under a second for typical banks); zero stutter on play. Non-looping playback is bit-identical to 1.24.2.
1.24.4 — The "Fail The Same Way Everywhere" Release
Hotfix for a WASM/native parity bug that hid fatal runtime errors on the web.
Every event handler, timer callback, and spawned BASIC thread runs on a child ExecContext cloned from the parent. By design the child shares some signals (killed, paused — both Arc<AtomicBool>) and keeps others local (halted). runtime_error only set the local halted flag, so a fatal error inside an event handler halted that handler and nothing else. Native got rescued accidentally because its system-bus handler in crashplayer reads SystemCommand::Exit and flips the shared kill_signal. WASM's handler treated Exit as a no-op, so the parent WHILE NOT keydown("Q") / YIELD main loop kept spinning forever and the user saw a working game with a silently broken event handler.
One-line fix in error::runtime_error: in addition to ctx.halt(), also call ctx.kill() — the shared flag, which propagates across every child immediately. Both platforms now stop the same way, no host-specific rescue path required. Three regression tests guard the cross-context propagation path.
1.24.5 — The "Behaviors Now Behave" Release
Two independent fixes for behavior-language primitives that quietly disagreed with their docs.
VARIANCE(low, high) collapsed when low was negative. resolve_variant was computing base + rng() * (low + high) - low, which silently encoded a "low is a magnitude to subtract from base" interpretation. VARIANCE(-5, 5) on base = 0 therefore gave 5.0 every single tick (random factor multiplied by zero). Anything written as VARIANCE(±x, ±x) to mean "scatter symmetrically around base" was broken — including the BVELOCITY 4 VARIANCE(-3, 3) example in the BFRICTION docs. Fix matches VARIANCEINT semantics: low and high are signed offsets from base, result is uniform in [base + low, base + high]. Small behavior change you'll only see in pathological positive-low cases: VARIANCE(1, 2) on base 0 used to give [-1, 2] (range of 3) and now gives [1, 2] (range of 1). Most usage in the wild was the symmetric VARIANCE(-x, x) form, which was the case the bug actually broke.
Pattern-step FADE [SPEED n] ignored its SPEED argument and snapped alpha to zero. The dispatcher had a long-standing TODO that just emitted a single SetSpriteAlpha(0.0) and advanced the step on the same tick. Whether you wrote FADE, FADE SPEED 0.2, or FADE SPEED 1000, the sprite vanished instantly. Fixed with a small per-instance alpha tween: total_ticks = max(round(60 / speed), 1), captures starting alpha via the new BehaviorEngine::get_sprite_alpha() getter (so a sprite mid-fade-from-something-else doesn't pop back to 1.0), parks the step until elapsed ≥ total. Cleared on GOTOPATTERN.
1.25.0 — The "Per-Sprite Alpha And Loud Shader Errors" Release
Two coordinated changes to the shader pipeline.
Per-sprite alpha for shader-using sprites. Compiled shader Uniforms struct now carries balpha: f32, populated per-sprite from the sprite's current alpha. The compiler injects an alpha multiplier at the end of every fragment shader so the per-sprite value gates the final output:
return vec4<f32>(your_color.rgb, your_color.a * uniforms.balpha);Author writes their SHADER ... WHEN DRAWING ... END SHADER exactly as before — the multiply is purely a compiler injection. Previously alpha was written to a per-texture uniform via set_texture_alpha, so two sprites sharing a texture but with different alphas got "whatever the final particle that touched the texture this frame was at." The shader uniform layout grew from 4 + 64 f32s (272 bytes) to 8 + 64 f32s (288 bytes) to make room for balpha plus WGSL's vec4 alignment padding. Every renderer site that builds a shader uniform buffer goes through a single shader_uniform_header() helper and the size is captured in SHADER_UNIFORM_BUFFER_SIZE_BYTES so the layout can't drift.
WGSL compile failures now surface as fatal runtime errors. A typo in custom WGSL used to land in console.error on WebGPU and stderr on native, with no plumbing to the dialog. The renderer registered the broken shader anyway, every frame's SetPipeline(InvalidPipeline) re-fired the same validation error, and Chrome eventually cut off the device with WebGPU: too many warnings. WgpuRenderer::create_shader now snapshots LAST_GPU_ERROR before/after pipeline creation and refuses to insert the broken pipeline; the Err propagates through the GfxEvent::Error → error::shader_gpu_error → runtime_error("SHADER", ...) → ctx.halt() / ctx.kill() chain that 1.24.4 already wired up.
Explicit deferral: default-rendered sprites still went through the per-texture path. The leak could still manifest if two default-rendered sprites shared a texture and had different alphas at draw time. Closed in 1.25.1.
1.25.1 — The "Default Sprites Get Per-Sprite Alpha Too" Release
Closes the gap 1.25.0 explicitly deferred. Engine init registers a reserved built-in shader __crashbasic_passthrough (a one-statement SET COLOR = IMAGE compiled through the same compile_shader path as user shaders, so it inherits the compiler-injected * uniforms.balpha multiply). The renderer's SpriteStateUpdate handler reactively auto-attaches it: any time a sprite's alpha drops below 1.0 and the sprite has no shader assigned, the handler calls set_sprite_shader(id, Some("__crashbasic_passthrough")) before writing the alpha. From that point the sprite has its own per-sprite uniform buffer and the leak is gone.
The decision is reactive, not proactive: a sprite that stays at 1.0 forever never gets the passthrough; a sprite that animates 1.0 → 0.0 gets it on the first sub-1.0 update and keeps it (detaching at 1.0 would thrash the registry, and the multiply is a no-op at 1.0 anyway). User-set SET SPRITE n SHADER "MyShader" always wins.
register_builtin_shaders warns instead of halting when the underlying graphics backend can't accept the shader (typical for the headless CaptureBus used in unit tests).
1.26.0 — The "Dissolve Actually Dissolves" Release
Four connected fixes that close out the per-sprite-alpha migration started in 1.25.0.
Built-in CLS-effect shaders honor the new layout. The seven hand-written WGSL shaders in crashgfx/src/builtin_shaders.rs (FADE / MELT / DISSOLVE / BLOCK / WIPEX / WIPEY + the layer wrap shader) kept the old four-f32 header. Their WGSL still declared params: array<vec4<f32>, 16> immediately after screen_height, so at runtime they read params[0] from byte offset 16 — exactly where the renderer was now writing balpha = 1.0 every frame. CLS 4 EFFECT(DISSOLVE) SPEED(60) flashed to red instantly because DISSOLVE read progress = params[0].x, which was always 1.0. Same root cause for all six effects — every transition compressed to a single instantaneous frame. Fix: add balpha: f32 + three padding f32s between header and params in every built-in shader's Uniforms struct.
Layer-wrap pipeline GPU buffer matches its WGSL declaration. The Segment::WrappedLayer branch was hand-rolling a 272-byte uniform Vec while the WGSL expected the full 288-byte layout, so wgpu's pipeline-creation flooded with LateMinBufferBindingSizeMismatch validation errors and every wrapped-layer draw failed (black screen for any scene using a wrapped layer). Routed through the same shader_uniform_header() helper every other built-in pipeline already uses.
Per-sprite balpha survives the per-frame full-buffer rewrite. Two functions wrote to the per-sprite uniform buffer and stomped each other: update_sprite_balpha did a 4-byte write at byte offset 16, and update_sprite_shader_params did a full 288-byte rewrite once per frame from tick_shaders(). The full rewrite hardcoded balpha = 1.0 in the header — comment on the line literally said "per-sprite alpha hookup lands in a follow-up commit." A BAlpha-driven fade was visible only for the single frame between the two writes (or not at all, depending on which call landed last). New current_balpha: f32 field on SpriteShaderState that both writers agree on.
WGSL pre-validated through naga before wgpu touches it. 1.25.0's error-capture path snapshotted LAST_GPU_ERROR before and after device.create_shader_module(). That works on native but not on WebGPU — the uncaptured-error callback is a JS callback that fires on the next event-loop tick, so any value read at the synchronous return site is necessarily stale. The shader got registered as "OK", subsequent pipelines cascade-failed every frame, and Chrome cut off the device. WgpuRenderer::create_shader now parses + validates the WGSL through naga (wgpu's own shader translator, already a transitive dep at the matching version) before handing the source to wgpu. Synchronous on every backend, returns Result<Module, ParseError>. Parse errors get the source-pointing diagnostic via err.emit_to_string(wgsl) — the exact multi-line format wgpu logged to the console — so the dialog reads like a familiar compile error.
The one new public surface across the migration is Renderer::has_sprite_shader(sprite_id) -> bool, used by the auto-attach decision path.
What didn't change across the whole arc
Existing user shaders continue to compile and run identically — the naga pre-validation accepts everything wgpu accepts. STOPMUSIC / PAUSEMUSIC / RESUMEMUSIC / FADEMUSIC are unchanged; the music bank file format is unchanged; old banks load and play exactly as before. SPRITETOUCHING$ syntax is unchanged — same arguments, same return type, same edge names; only its accuracy improved. Native audio behavior is untouched by the WASM-specific cache and worker-pool work. VARIANCEINT was already correct and is unchanged. 1277 unit tests pass unchanged from 1.24.0 through 1.26.0.
If you want the full per-release detail, every individual RELEASE_NOTES_1.24.x.md / 1.25.x.md / 1.26.0.md is still in docs/.