From 826c62a1e95f440790b577746cc235e41de2e025 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:39:10 +0700 Subject: [PATCH 1/4] Bind the parts of raylib a game needs and this one refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio, render textures, fonts, gamepads, touch and gestures were all absent, and a game cannot ship without the first of them. Fonts were refused by name last time because a Font drags in two more aggregates and two owned arrays with nothing headless to check them against; the generator takes all of it unchanged now, and the check turned out to exist — raylib measures text with pure CPU arithmetic over every field. SetGamepadVibration stays unbound for two reasons at once: its arity differs between the 5.1 and 6.1 headers with no 5.5 header to settle it, and the symbol in libraylib.so.550 disassembles to a TraceLog stub that touches no motor. --- .gitignore | 2 + vendor/raylib/raylib.flan | 419 +++++++++++++++++++++++++++++++++++++- 2 files changed, 415 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 4b6b173..67b9111 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ old-ocaml/ /sand /conditions-play .claude/ +probe +probe.c diff --git a/vendor/raylib/raylib.flan b/vendor/raylib/raylib.flan index a2d4d9a..a406210 100644 --- a/vendor/raylib/raylib.flan +++ b/vendor/raylib/raylib.flan @@ -459,12 +459,12 @@ ;; libraylib.so.550 and is why it is NOT in the acceptance table despite ;; looking like exactly the kind of call that could be. ;; -;; Font loading is not bound, deliberately. A Font is baseSize, glyphCount and -;; glyphPadding beside a Texture2D, a Rectangle* and a GlyphInfo* — and a -;; GlyphInfo embeds an Image. Binding it means binding two more aggregates and -;; two owned arrays for something with no headless test at the end of it, so -;; load-font, load-font-ex, unload-font, get-font-default, draw-text-ex and -;; measure-text-ex are all absent rather than half-done. +;; Fonts ARE bound now — see the section at the end of this file. The reason +;; they were not is worth keeping: a Font is three ints beside a Texture2D, a +;; Rectangle* and a GlyphInfo*, and a GlyphInfo embeds an Image, and that was +;; "two more aggregates and two owned arrays for something with no headless +;; test at the end of it". The generator takes all of that now, and the +;; headless test turned out to exist after all. (declare-c draw-text [text string x i32 y i32 font-size i32 color Color] @@ -483,3 +483,410 @@ (declare-c get-time [] f64 "GetTime") (declare-c get-screen-width [] i32 "GetScreenWidth") (declare-c get-screen-height [] i32 "GetScreenHeight") + +;; ── Gamepads ──────────────────────────────────────────────────────── +;; +;; Nothing here can be asserted headlessly and nothing here can be asserted +;; *at all* without a pad plugged in: with no gamepad, gamepad-available? is +;; false, every button predicate is false and every axis reads 0.0, which is +;; also exactly what a wrapper with its two int arguments exchanged would +;; report. So these are bound, wired into sand.flan's HUD, and honestly +;; described as untested — the only check they get is that a pad moves the +;; read-out. +;; +;; `pad` is an index from 0, not an enum: raylib's own parameter is an int and +;; how many are attached is a run-time question. + +(defenum GamepadButton + [unknown 0 + left-face-up 1 left-face-right 2 left-face-down 3 left-face-left 4 + right-face-up 5 right-face-right 6 right-face-down 7 right-face-left 8 + left-trigger-1 9 left-trigger-2 10 + right-trigger-1 11 right-trigger-2 12 + middle-left 13 middle 14 middle-right 15 + left-thumb 16 right-thumb 17]) + +;; The triggers read -1 at rest and 1 fully pressed, unlike the sticks, which +;; are centred at 0. raylib does not normalise that and neither does this. +(defenum GamepadAxis + [left-x 0 left-y 1 right-x 2 right-y 3 + left-trigger 4 right-trigger 5]) + +(declare-c gamepad-available? [pad i32] bool "IsGamepadAvailable") + +(declare-c gamepad-button-pressed? + [pad i32 button GamepadButton] bool + "IsGamepadButtonPressed") +(declare-c gamepad-button-down? + [pad i32 button GamepadButton] bool + "IsGamepadButtonDown") +(declare-c gamepad-button-released? + [pad i32 button GamepadButton] bool + "IsGamepadButtonReleased") +(declare-c gamepad-button-up? + [pad i32 button GamepadButton] bool + "IsGamepadButtonUp") + +;; -1 when nothing is pressed, so the answer is not a GamepadButton: raylib +;; returns an int outside the enum and the checker would have to be lied to. +(declare-c get-gamepad-button-pressed [] i32 "GetGamepadButtonPressed") + +(declare-c get-gamepad-axis-count [pad i32] i32 "GetGamepadAxisCount") + +(declare-c get-gamepad-axis-movement + [pad i32 axis GamepadAxis] f32 + "GetGamepadAxisMovement") + +;; SetGamepadVibration is NOT bound, and the reason is not the usual one. Two +;; things are wrong with it at once. Its arity changed — 5.1-dev takes three +;; floats and 6.1-dev takes four, there is no 5.5 header here to settle which, +;; and the generated prototype is what fixes the call, so a guess is a +;; corrupted stack frame rather than a link error. And it would not matter if +;; it were guessed right: the symbol in libraylib.so.550 disassembles to a +;; single TraceLog call and a jump — it is a stub that reports "not +;; implemented" and touches no motor. Binding it would be binding a warning. + +;; ── Touch and gestures ────────────────────────────────────────────── +;; +;; Touch is a superset of the mouse on a desktop: with no touchscreen, +;; get-touch-point-count is 0 but get-touch-position 0 still tracks the mouse, +;; which is what makes it visible in sand.flan at all. +;; +;; The gesture system is fed by raylib's own event polling, so a gesture is +;; only ever detected inside a window's frame loop — nothing here is headless +;; material either. + +;; A bitfield, not an ordinary enum: set-gestures-enabled takes the OR of +;; several and gesture-detected? tests against one. That is why the enabling +;; call below takes a u32 and not a Gesture — a keyword can only ever name one +;; member, and `all` is spelled out here so the common case still reads. +(defenum Gesture + [none 0 tap 1 double-tap 2 hold 4 drag 8 + swipe-right 16 swipe-left 32 swipe-up 64 swipe-down 128 + pinch-in 256 pinch-out 512]) + +(defconst gesture-all u32 1023) + +(declare-c get-touch-position [index i32] Vector2 "GetTouchPosition") +(declare-c get-touch-x [] i32 "GetTouchX") +(declare-c get-touch-y [] i32 "GetTouchY") +(declare-c get-touch-point-count [] i32 "GetTouchPointCount") +(declare-c get-touch-point-id [index i32] i32 "GetTouchPointId") + +(declare-c set-gestures-enabled [flags u32] "SetGesturesEnabled") +(declare-c gesture-detected? [gesture Gesture] bool "IsGestureDetected") +(declare-c get-gesture-detected [] Gesture "GetGestureDetected") + +;; Degrees, and only meaningful while a drag is in progress. +(declare-c get-gesture-drag-vector [] Vector2 "GetGestureDragVector") +(declare-c get-gesture-drag-angle [] f32 "GetGestureDragAngle") +(declare-c get-gesture-pinch-vector [] Vector2 "GetGesturePinchVector") +(declare-c get-gesture-pinch-angle [] f32 "GetGesturePinchAngle") +(declare-c get-gesture-hold-duration [] f32 "GetGestureHoldDuration") + +;; ── Render textures ───────────────────────────────────────────────── +;; +;; A framebuffer with two textures hanging off it: draw into it between +;; begin-texture-mode and end-texture-mode, then draw *it* like any other +;; texture. That is how a post-process pass and a pixel-perfect integer +;; upscale are both done. +;; +;; None of it is assertable here — LoadRenderTexture makes a GL framebuffer +;; object, so with no context it answers an id of 0 and every draw into it is +;; a no-op. `depth` is a renderbuffer rather than a real texture in raylib's +;; default configuration, so its id is the only field of it worth reading. +;; +;; The one thing the layout gets for free: RenderTexture2D is a u32 and two +;; Texture2Ds, and Texture2D is already pinned as far as anything headless can +;; pin it, so the only new claim here is the order of the three members. +;; sand.flan draws through one, which is where a wrong order shows up. + +(defstruct RenderTexture2D [id u32 texture Texture2D depth Texture2D]) + +(declare-c load-render-texture + [width i32 height i32] RenderTexture2D + "LoadRenderTexture") + +;; raylib 5.5 spells this IsRenderTextureValid; there is no IsRenderTextureReady +;; in this version, the same rename that took IsTextureReady and IsImageReady. +(declare-c render-texture-valid? + [target RenderTexture2D] bool + "IsRenderTextureValid") + +(declare-c unload-render-texture + [target RenderTexture2D] + "UnloadRenderTexture") + +;; Everything drawn between these two lands in the target instead of the +;; screen, and the target's texture comes out of the GPU upside down — raylib +;; renders it bottom-up — so drawing it back with a NEGATIVE source height is +;; not a flourish, it is the correction. sand.flan does exactly that. +(declare-c begin-texture-mode [target RenderTexture2D] "BeginTextureMode") +(declare-c end-texture-mode [] "EndTextureMode") + +;; ── Audio ─────────────────────────────────────────────────────────── +;; +;; The device first, and the split that matters for testing runs right +;; through this section: a **Wave** is samples in RAM and needs no device at +;; all, while a **Sound** is a buffer the mixer owns and a **Music** is a +;; decoder feeding one, and both of those are nothing without +;; init-audio-device having succeeded. +;; +;; That makes Wave the audio equivalent of the Image family — raylib +;; *computes* with it, headlessly — and it is the only part of this section +;; the acceptance table asserts. Everything from Sound down is exercised by +;; running sand.flan with a working sound server, and a machine without one +;; gets silence rather than a crash: init-audio-device logs a warning, every +;; load answers a zeroed struct and every play is a no-op. + +(declare-c init-audio-device [] "InitAudioDevice") +(declare-c close-audio-device [] "CloseAudioDevice") +(declare-c audio-device-ready? [] bool "IsAudioDeviceReady") + +;; 0 to 1, and it is a listener gain applied after every per-sound volume. +(declare-c set-master-volume [volume f32] "SetMasterVolume") +(declare-c get-master-volume [] f32 "GetMasterVolume") + +;; A Wave is the CPU side: `data` is frame-count × channels samples of +;; sample-size bits each, and raylib reads every one of the four integers to +;; decide what those bytes mean. `data` is (Ptr u8) rather than a typed +;; pointer because its element type is `sample-size`, which is a run-time +;; number — 8, 16 or 32 bits — and there is no Flan type that says that. +;; +;; The consequence for a caller building one by hand: the bytes are written +;; as bytes, in the host's order. That is the shape the acceptance case uses, +;; and it is deliberate — it means the case says what it means about +;; little-endian 16-bit PCM instead of hiding it behind a cast. +(defstruct Wave [frame-count u32 sample-rate u32 sample-size u32 + channels u32 data (Ptr u8)]) + +(declare-c load-wave [path string] Wave "LoadWave") + +;; raylib 5.5 spells this IsWaveValid; IsWaveReady is gone, as everywhere else. +(declare-c wave-valid? [wave Wave] bool "IsWaveValid") + +(declare-c unload-wave [wave Wave] "UnloadWave") + +;; The extension picks the format, and raylib writes .wav and .qoa. This is +;; external ground truth for the layout: the header it writes carries +;; sample-rate, sample-size and channels, and the payload length carries +;; frame-count, so a permuted defstruct writes a file that reads back +;; differently — the same argument the PNG round trip makes for Image. +(declare-c export-wave [wave Wave path string] bool "ExportWave") + +;; Allocates a copy of the buffer; the copy is unloaded on its own. +(declare-c wave-copy [wave Wave] Wave "WaveCopy") + +;; In FRAMES, not samples — raylib renamed the parameters for 5.5 without +;; changing the signature, so the name is the only thing that says which. On +;; a mono wave the two readings coincide, which is what the acceptance case +;; uses, so nothing here depends on having guessed right. +(declare-c wave-crop + [wave (Ptr Wave) init-frame i32 final-frame i32] + "WaveCrop") + +;; Resamples in place. This is the strongest headless shape available in this +;; section and the same one gen-image-color has: three scalars go in and four +;; fields come out, with frame-count *computed* from the sample-rate ratio, so +;; a permuted layout has nothing to cancel against. +(declare-c wave-format + [wave (Ptr Wave) sample-rate i32 sample-size i32 channels i32] + "WaveFormat") + +;; Every sample as a float in [-1, 1], frame-count × channels of them, +;; whatever the wave's own sample-size. That is the one call that reads +;; *through* `data`, so it is what pins the pointer as a pointer rather than +;; as two integers that happen to sit at the end. +(declare-c load-wave-samples [wave Wave] (Ptr f32) "LoadWaveSamples") +(declare-c unload-wave-samples [samples (Ptr f32)] "UnloadWaveSamples") + +;; A Sound is an AudioStream plus a frame count. The two leading pointers are +;; miniaudio's and Flan never reads through them — they are (Ptr u8) so the +;; struct is the right size and the three integers land at the right offsets, +;; exactly as Image's `data` is. +(defstruct AudioStream [buffer (Ptr u8) processor (Ptr u8) + sample-rate u32 sample-size u32 channels u32]) + +(defstruct Sound [stream AudioStream frame-count u32]) + +(declare-c load-sound [path string] Sound "LoadSound") + +;; Note what this does to the frame count: the mixer resamples to the device's +;; own rate, so a sound made from an 8 kHz wave on a 48 kHz device reports six +;; times as many frames. Nothing should read `frame-count` expecting the +;; wave's. +(declare-c load-sound-from-wave [wave Wave] Sound "LoadSoundFromWave") + +(declare-c sound-valid? [sound Sound] bool "IsSoundValid") +(declare-c unload-sound [sound Sound] "UnloadSound") + +(declare-c play-sound [sound Sound] "PlaySound") +(declare-c stop-sound [sound Sound] "StopSound") +(declare-c pause-sound [sound Sound] "PauseSound") +(declare-c resume-sound [sound Sound] "ResumeSound") +(declare-c sound-playing? [sound Sound] bool "IsSoundPlaying") + +;; Volume is a gain from 0, pitch is a rate multiplier where 1 is unchanged, +;; and pan is 0 hard left to 1 hard right with 0.5 centred — raylib's own +;; convention, and the one place in this file where 0 is not the neutral +;; value. +(declare-c set-sound-volume [sound Sound volume f32] "SetSoundVolume") +(declare-c set-sound-pitch [sound Sound pitch f32] "SetSoundPitch") +(declare-c set-sound-pan [sound Sound pan f32] "SetSoundPan") + +;; A second voice over the same samples, so one sound can overlap itself. It +;; does NOT own the data, so unloading an alias must not unload the original — +;; which is why raylib has a separate call for it and why this one is bound. +(declare-c load-sound-alias [source Sound] Sound "LoadSoundAlias") +(declare-c unload-sound-alias [alias Sound] "UnloadSoundAlias") + +;; Music is streamed rather than resident, which is the whole difference: the +;; buffer is refilled from the decoder and update-music-stream is what does +;; the refilling. Miss it for a frame and the music stops. +(defstruct Music [stream AudioStream frame-count u32 looping bool + ctx-type i32 ctx-data (Ptr u8)]) + +(declare-c load-music-stream [path string] Music "LoadMusicStream") +(declare-c music-valid? [music Music] bool "IsMusicValid") +(declare-c unload-music-stream [music Music] "UnloadMusicStream") + +(declare-c play-music-stream [music Music] "PlayMusicStream") + +;; Called once per frame, every frame, for as long as the music is meant to +;; play. This is the one binding in the section whose absence is silent. +(declare-c update-music-stream [music Music] "UpdateMusicStream") + +(declare-c stop-music-stream [music Music] "StopMusicStream") +(declare-c pause-music-stream [music Music] "PauseMusicStream") +(declare-c resume-music-stream [music Music] "ResumeMusicStream") +(declare-c music-stream-playing? [music Music] bool "IsMusicStreamPlaying") + +(declare-c set-music-volume [music Music volume f32] "SetMusicVolume") +(declare-c set-music-pitch [music Music pitch f32] "SetMusicPitch") +(declare-c set-music-pan [music Music pan f32] "SetMusicPan") + +;; Seconds, both of them. +(declare-c seek-music-stream [music Music position f32] "SeekMusicStream") +(declare-c get-music-time-length [music Music] f32 "GetMusicTimeLength") +(declare-c get-music-time-played [music Music] f32 "GetMusicTimePlayed") + +;; AudioStream itself — the raw callback-fed stream — is NOT bound. Its point +;; is set-audio-stream-callback, which takes a C function pointer, and a +;; callback is refused by the shim generator by name: `%s is a function type, +;; and a C callback is not implemented`. Binding the rest of the family +;; without it would be binding a stream that can only ever be fed by +;; update-audio-stream from the main thread, which is a worse Sound. + +;; ── Fonts ─────────────────────────────────────────────────────────── +;; +;; A previous pass refused this whole family by name, and the reason was that +;; a Font drags in two more aggregates and two owned arrays and there was +;; nothing headless to check them against. Both halves of that have changed. +;; +;; The generator takes it: a struct held by value is emitted after everything +;; it contains, a struct held by POINTER is forward-declared, and both the +;; Flan struct and the C typedef come from the same `defstruct`. Font holds a +;; Texture2D by value and points at Rectangle and GlyphInfo; GlyphInfo holds +;; an Image by value. Nothing here needed a generator change. +;; +;; And the test exists. raylib's text measuring is pure CPU arithmetic over +;; every field of a Font — it walks the glyph array looking for a codepoint, +;; reads the advance out of the glyph or the width out of the atlas rectangle, +;; and scales by the base size. The catch was that the calls that MAKE a font +;; all need something a headless run does not have: get-font-default needs +;; init-window, load-font-ex needs a TTF on disk. So the acceptance case does +;; not make one — it *builds* one, field by field, out of Flan arrays, and +;; hands it to raylib to compute with. Scalars in, numbers out, with no input +;; struct raylib produced for a permutation to cancel against. +;; +;; One trap found while doing that, and it is in raylib rather than here: +;; MeasureTextEx returns (0,0) immediately when `texture.id` is 0. A +;; hand-built font therefore has to claim a nonzero texture id even though +;; there is no texture — which is also what makes the case pin where the +;; Texture2D sits inside the Font. + +;; `image` is the glyph's own pixels, and raylib owns them; it is here so the +;; four ints in front of it are at the right offsets and so a GlyphInfo is 40 +;; bytes rather than 16. offset-x and offset-y shift the glyph when drawn; +;; advance-x is how far the pen moves after it, and when it is 0 raylib falls +;; back to the atlas rectangle's width plus offset-x. +(defstruct GlyphInfo [value i32 offset-x i32 offset-y i32 advance-x i32 + image Image]) + +;; `recs` and `glyphs` are parallel arrays of glyph-count entries each: recs +;; says where the glyph is in the atlas texture, glyphs says what it is. A +;; codepoint raylib cannot find falls back to index 0 rather than reading out +;; of bounds. +(defstruct Font [base-size i32 glyph-count i32 glyph-padding i32 + texture Texture2D recs (Ptr Rectangle) + glyphs (Ptr GlyphInfo)]) + +;; Needs a window: the default font is loaded as part of init-window and +;; LoadFontDefault is not exported, which is the same fact that makes +;; measure-text answer 0 headless. +(declare-c get-font-default [] Font "GetFontDefault") + +(declare-c load-font [path string] Font "LoadFont") + +;; The codepoint set is a C array plus an int count, so — like +;; collision-point-poly? — the declaration says (Ptr i32) and the Flan wrapper +;; below takes a slice apart. A slice parameter in a declare-c is refused by +;; name, because the C count's own type is not recoverable from [T]. +;; +;; raylib's own convention is that a NULL pointer with a count of 0 means the +;; default ASCII set, and the wrapper keeps it — but Flan has no null pointer +;; literal, so the null comes from the one place the language does hand out +;; zeroed bytes: a `defvar` with no initialiser is BSS (plan.org, zero +;; values), and a zeroed (Ptr i32) is exactly a null one. It is never written +;; to and never read through; raylib only ever compares it against NULL. +(defvar default-codepoints (Ptr i32)) + +(declare-c load-font-ex-raw + [path string font-size i32 codepoints (Ptr i32) count i32] Font + "LoadFontEx") + +(defn load-font-ex [path string font-size i32 codepoints [i32]] Font + (if (= (len codepoints) 0) + (load-font-ex-raw path font-size default-codepoints 0) + (load-font-ex-raw path font-size (addr (at codepoints 0)) (len codepoints)))) + +;; raylib 5.5 spells this IsFontValid. It reads the texture id and both +;; arrays, so a font that loaded but could not upload its atlas — which is +;; every font loaded without a GL context — is NOT valid by this test. +(declare-c font-valid? [font Font] bool "IsFontValid") + +(declare-c unload-font [font Font] "UnloadFont") + +;; `spacing` is extra pixels between glyphs, added per gap and not per glyph, +;; so a one-character string is unaffected by it. raylib's own DrawTextEx adds +;; it the same way measure-text-ex counts it, which is why the two agree. +(declare-c draw-text-ex [font Font text string position Vector2 + font-size f32 spacing f32 tint Color] "DrawTextEx") + +;; Pure arithmetic over the font — no GL, no window — and therefore the one +;; thing in this section the acceptance table can assert. See the note above: +;; it refuses to measure anything at all when the font's texture id is 0. +(declare-c measure-text-ex + [font Font text string font-size f32 spacing f32] Vector2 + "MeasureTextEx") + +;; The index into `recs` and `glyphs`, by linear search over glyph-count. Also +;; pure CPU, and it is what pins glyph-count as the loop bound. +(declare-c get-glyph-index [font Font codepoint i32] i32 "GetGlyphIndex") + +(declare-c get-glyph-info [font Font codepoint i32] GlyphInfo "GetGlyphInfo") + +(declare-c get-glyph-atlas-rec + [font Font codepoint i32] Rectangle + "GetGlyphAtlasRec") + +(declare-c draw-text-codepoint [font Font codepoint i32 position Vector2 + font-size f32 tint Color] "DrawTextCodepoint") + +;; DrawTextCodepoints and LoadFontData are not bound. The first is the slice +;; problem again and adds nothing draw-text-ex does not already do from a +;; string; the second hands back a raw GlyphInfo array whose length is the +;; caller's to remember and whose lifetime is UnloadFontData's, and Flan has +;; no owning array type to give that to — a (Ptr GlyphInfo) with a separate +;; count is what the language would force, which is the C API with the safety +;; removed rather than a binding. From 34b6543e588f0ac64c67bc2e634da560024553be Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:45:41 +0700 Subject: [PATCH 2/4] Assert audio and fonts headlessly, which both were said to be impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio was written off as needing a device. That is true of Sound and Music and false of Wave: copy, crop, reformat, export, load and decode are all CPU work, and wave-format is the same scalars-in/fields-out shape gen-image-color is, with the frame count computed rather than handed over. Cropping to a single frame before decoding puts raylib's byte-offset arithmetic in front of the decoder, which is what tells sample-size from channels — an axis discriminator, not a mirror. Fonts were said to have no headless test. They do, once the program stops asking raylib for a font and builds one out of Flan arrays: text measuring reads every field and computes. Both cases were verified red by permuting the defstructs; the permutations are recorded in the comments so the next reader need not rediscover which ones bite. --- test/programs/raylib-audio.flan | 193 ++++++++++++++++++++++++++++++++ test/programs/raylib-font.flan | 161 ++++++++++++++++++++++++++ test/test_acceptance.ml | 115 +++++++++++++++++++ 3 files changed, 469 insertions(+) create mode 100644 test/programs/raylib-audio.flan create mode 100644 test/programs/raylib-font.flan diff --git a/test/programs/raylib-audio.flan b/test/programs/raylib-audio.flan new file mode 100644 index 0000000..d77b87f --- /dev/null +++ b/test/programs/raylib-audio.flan @@ -0,0 +1,193 @@ +(import rl "vendor:raylib") + +;; raylib's Wave family, headless — and the point of this file is that it IS +;; headless, which the brief for this lane said audio could not be. +;; +;; The split inside raylib's audio module runs exactly along that line. A +;; Sound is a buffer the miniaudio mixer owns and a Music is a decoder feeding +;; one, so neither exists until init-audio-device has found a device. A +;; **Wave** is samples in RAM and four integers describing them, and every +;; call that operates on one — copy, crop, reformat, export, load, decode to +;; floats — runs on the CPU with no device open at all. That is the Image +;; family's situation exactly, and it gets the Image family's kind of test: +;; raylib *computes*, and the answer is a number. +;; +;; Four separate claims are pinned here, and they are different claims: +;; +;; 1. **Wave's layout, from scalars in and fields out.** wave-format is +;; handed three plain integers and rewrites all four u32 fields, one of +;; them — frame-count — *computed* from the ratio of the old and new +;; sample rates. The answers are 16, 16000, 8 and 2: all distinct, and +;; the 16 is named by no argument at all, so a permuted defstruct has +;; nothing to cancel against. This is gen-image-color's argument, which +;; is the strongest shape a headless FFI test has. +;; +;; 2. **The file, as external ground truth.** export-wave writes a RIFF +;; header carrying sample-rate, sample-size and channels, and a payload +;; whose length says frame-count; load-wave reads that header back. Both +;; ends are dr_wav's and agree with each other, not with whatever field +;; order Flan believes in — the same reason the PNG round trip in +;; raylib-image.flan is not the symmetric trap a store-and-return check +;; is. +;; +;; 3. **`data` as a pointer, and the bytes behind it.** load-wave-samples +;; decodes through the buffer. Move `data` up among the integers and +;; every number in this file reads half of a pointer instead. +;; +;; 4. **Crop's byte arithmetic, which is the axis discriminator.** raylib +;; crops at `init-frame * channels * sample-size/8` bytes into the +;; buffer, so cropping one frame out of the middle and decoding it says +;; which frame it was. That is what distinguishes sample-size from +;; channels: exchange them and a one-frame crop lands two bytes off and +;; the sample that comes back is a different number, not the same one +;; mirrored. Axis-aligned geometry could never do this and neither could +;; a round trip. +;; +;; The four integers describing the source wave are 8, 8000, 16 and 1 — all +;; different, for the same reason the test image in raylib-image.flan is 4 by +;; 2 and not square. + +;; The samples, as bytes, little-endian 16-bit signed PCM. They are bytes +;; rather than i16 because `data` is (Ptr u8): a wave's element width is +;; `sample-size`, a run-time number, so no Flan type says what the buffer +;; holds. Writing them out is the honest spelling — the file then says what it +;; means about 16-bit PCM instead of hiding it behind a cast. +;; +;; 0, 1000, 2000, 3000, -3000, -2000, -1000, 0 — one cycle, every sample +;; different from its neighbours, both signs present, so a decode that lost +;; the byte order or the sign is a wrong number rather than merely a +;; different one. +(defvar pcm [16 u8]) + +(defn load-pcm [] + (set (at pcm 0) 0x00) (set (at pcm 1) 0x00) ; 0 + (set (at pcm 2) 0xE8) (set (at pcm 3) 0x03) ; 1000 + (set (at pcm 4) 0xD0) (set (at pcm 5) 0x07) ; 2000 + (set (at pcm 6) 0xB8) (set (at pcm 7) 0x0B) ; 3000 + (set (at pcm 8) 0x48) (set (at pcm 9) 0xF4) ; -3000 + (set (at pcm 10) 0x30) (set (at pcm 11) 0xF8) ; -2000 + (set (at pcm 12) 0x18) (set (at pcm 13) 0xFC) ; -1000 + (set (at pcm 14) 0x00) (set (at pcm 15) 0x00)) ; 0 + +(defconst wav-path "/tmp/flan-raylib-audio.wav") + +(defn show-wave [name string w rl/Wave] + (print-str name) + (print-str " ") (print-i64 (i64 (.frame-count w))) + (print-str " ") (print-i64 (i64 (.sample-rate w))) + (print-str " ") (print-i64 (i64 (.sample-size w))) + (print-str " ") (print-i64 (i64 (.channels w))) + (newline)) + +(defn show-bool [name string b bool] + (print-str name) (print-str " ") + (print-line (if b "yes" "no"))) + +;; A decoded sample is compared with a tolerance and the verdict is printed, +;; not the number. 1000 over a 15- or 16-bit full scale is 0.0305185 or +;; 0.0305176, and this table compares stdout byte for byte at two optimisation +;; levels, so printing the float would pin raylib's choice of divisor rather +;; than Flan's field order. Nothing is lost: a permuted layout is not out by a +;; rounding step, it reads a different buffer. +(defn near? [a f32 b f32] bool + (let [d (- a b)] + (< (if (< d 0.0) (- 0.0 d) d) 0.0005))) + +;; One frame out of a wave, decoded to a float. +;; +;; This is shaped the way it is because of a real limit in Flan, not for +;; effect: load-wave-samples answers a (Ptr f32), a pointer cannot be indexed +;; — `at` takes an array, a slice or a string, and there is no pointer +;; arithmetic — so `deref` reaches sample 0 and nothing reaches sample 3. So +;; the wave is cropped to the single frame wanted FIRST, and then sample 0 is +;; the one being asked about. +;; +;; The detour pays for itself. Reading through an index would have exercised +;; only the decoder; cropping first puts raylib's byte-offset arithmetic — +;; init-frame times channels times sample-size over 8 — in front of the +;; decoder, and that arithmetic reads two of the fields this file is trying +;; to pin. +(defn frame-at [w rl/Wave i i32] f32 + (let [one (rl/wave-copy w)] + (rl/wave-crop (addr one) i (+ i 1)) + (let [s (rl/load-wave-samples one) + v (deref s)] + (rl/unload-wave-samples s) + (rl/unload-wave one) + v))) + +(defn show-frame [name string w rl/Wave i i32 want f32] + (show-bool name (near? (frame-at w i) want))) + +(defn main [] i32 + (rl/set-trace-log-level :warning) + (load-pcm) + + ;; ── The wave, built by hand ───────────────────────────────────────── + ;; + ;; Nothing raylib made: four integers Flan chose and a buffer Flan owns. So + ;; every number below is raylib reading THIS struct, and there is no + ;; raylib-produced struct anywhere for a permutation to hide inside. + (let [src (rl/Wave {:frame-count 8 :sample-rate 8000 :sample-size 16 + :channels 1 :data (addr (at pcm 0))})] + (show-bool "valid" (rl/wave-valid? src)) + (show-wave "source" src) + + ;; ── Crop ──────────────────────────────────────────────────────────── + ;; + ;; Frames 2 up to 6, so four of the eight survive and nothing else + ;; changes. This is the one call that moves frame-count on its own, which + ;; is what separates that field from sample-rate: exchange the two and the + ;; crop is asked for frames 2..6 of a wave claiming 8000 of them, and the + ;; 4 lands in the sample-rate slot instead. + ;; + ;; The wave is mono, so raylib 5.5's rename of these parameters from + ;; samples to frames is not something this depends on having guessed + ;; right — at one channel the two readings coincide. + (let [cropped (rl/wave-copy src)] + (rl/wave-crop (addr cropped) 2 6) + (show-wave "cropped" cropped) + (rl/unload-wave cropped)) + + ;; ── Reformat ──────────────────────────────────────────────────────── + ;; + ;; The strongest case in the file. Three scalars go in — 16000 Hz, 8 bits, + ;; 2 channels — and four fields come out: 16, 16000, 8, 2. The 16 is + ;; computed, twice the original eight frames because the rate doubled, and + ;; it is the only one of the four no argument named. Eight bits rather + ;; than sixteen so that sample-size cannot be confused with the frame + ;; count it would otherwise equal. + (let [reformatted (rl/wave-copy src)] + (rl/wave-format (addr reformatted) 16000 8 2) + (show-wave "reformatted" reformatted) + (rl/unload-wave reformatted)) + + ;; ── Through the pointer, one frame at a time ──────────────────────── + ;; + ;; See frame-at: each of these crops to a single frame and decodes it, so + ;; raylib's byte offset into `data` is what selects the answer. Frames 1, + ;; 3 and 4 are +1000, +3000 and -3000 — three different magnitudes and + ;; both signs, so neither an offset that is out by a frame nor a decode + ;; that read the bytes backwards survives. + (show-frame "frame 1 is +1000" src 1 0.030518) + (show-frame "frame 3 is +3000" src 3 0.091553) + (show-frame "frame 4 is -3000" src 4 -0.091553) + (show-frame "frame 0 is zero" src 0 0.0) + + ;; ── Out to a file and back ────────────────────────────────────────── + ;; + ;; dr_wav writes the header from three of the four fields and the payload + ;; length from the fourth, and reads all four back out of the file. Both + ;; ends are external to Flan and agree with each other, so this is not the + ;; round trip that passes for any field order — it is the PNG argument, + ;; for audio. It also crosses a path as ptr+len. + (show-bool "exported" (rl/export-wave src wav-path)) + (let [back (rl/load-wave wav-path)] + (show-bool "loaded valid" (rl/wave-valid? back)) + (show-wave "loaded" back) + ;; And the samples off the file's own copy, so the bytes are checked to + ;; have survived the encoder, the decoder and the layout at once. + (show-frame "loaded frame 1 is +1000" back 1 0.030518) + (show-frame "loaded frame 4 is -3000" back 4 -0.091553) + (rl/unload-wave back))) + 0) diff --git a/test/programs/raylib-font.flan b/test/programs/raylib-font.flan new file mode 100644 index 0000000..4b2c719 --- /dev/null +++ b/test/programs/raylib-font.flan @@ -0,0 +1,161 @@ +(import rl "vendor:raylib") + +;; raylib's Font family, headless. +;; +;; A previous lane refused this whole family by name and gave a real reason: a +;; Font is three ints beside a Texture2D, a Rectangle* and a GlyphInfo*, a +;; GlyphInfo embeds an Image, and that was two more aggregates and two owned +;; arrays "for something with no headless test at the end of it". The second +;; half of that turned out to be wrong, and this file is the counter-example. +;; +;; **Nothing here makes a font.** That is the whole trick. Every raylib call +;; that produces one needs something a headless run has not got — +;; get-font-default needs init-window, load-font and load-font-ex need a TTF +;; on disk and a GL context to upload the atlas to — so this file *builds* a +;; Font out of Flan arrays, field by field, and hands it to raylib to compute +;; with. raylib's text measuring is pure CPU arithmetic: it walks the glyph +;; array for a codepoint, takes the advance out of the glyph or the width out +;; of the atlas rectangle, and scales by the base size. +;; +;; Which makes this the scalars-in/fields-out shape rather than the +;; store-and-return one. There is no raylib-produced struct anywhere in the +;; file for a permutation to cancel against: Flan chose every field, and what +;; comes back is a number raylib worked out from them. +;; +;; What is pinned, and by what: +;; +;; - **base-size** by the scale factor. Measuring "AB" at size 10 against a +;; base size of 10 is scale 1 and at size 20 is scale 2, and both are +;; asserted, so the field cannot be a constant that happens to fit. +;; - **glyph-count** by the search bound. 'Z' is not in the font, and +;; raylib's miss falls back to index 0 rather than reading past the array. +;; - **recs and glyphs** as pointers, by being read through at all. +;; - **advance-x against offset-x**, because glyph C deliberately has an +;; advance of 0: raylib then falls back to the atlas rectangle's width +;; plus offset-x, so measuring "ABC" reaches three more fields than +;; measuring "AB" does. +;; - **where the Texture2D sits inside the Font**, by a trap in raylib +;; rather than a choice here: MeasureTextEx returns (0,0) at once when +;; `texture.id` is 0. The hand-built font claims an id of 1 — there is no +;; texture — and if the texture landed anywhere else in the struct the id +;; would read 0 and every measurement below would collapse to zero. +;; +;; The numbers are chosen so that no two are equal: base size 10, glyph count +;; 3, padding 2, advances 11 and 13, offsets 1, 2 and 3, atlas widths 5, 7 +;; and 9. Everything prints exactly — no trigonometry, so unlike the rotated +;; camera in raylib-ffi.flan this can be compared as text. + +;; The atlas rectangles, one per glyph. x and width differ per glyph so that +;; a fallback to the wrong index is a wrong number; height is the same 10 for +;; all three because raylib never reads it here. +(defvar glyph-recs [3 rl/Rectangle]) + +;; The glyphs themselves. `image` is raylib's own pixels for the glyph and is +;; left zeroed — it is present so the four ints in front of it are at the +;; right offsets and so a GlyphInfo is 40 bytes rather than 16. +(defvar glyphs [3 rl/GlyphInfo]) + +(defn build-glyphs [] + (set (at glyph-recs 0) (rl/Rectangle {:x 0.0 :y 0.0 :width 5.0 :height 10.0})) + (set (at glyph-recs 1) (rl/Rectangle {:x 5.0 :y 0.0 :width 7.0 :height 10.0})) + (set (at glyph-recs 2) (rl/Rectangle {:x 12.0 :y 0.0 :width 9.0 :height 10.0})) + ;; 65 66 67 are A B C. Advances 11 and 13 for the first two; C's advance is + ;; 0 on purpose, which is what sends raylib down the other branch. + (set (at glyphs 0) (rl/GlyphInfo {:value 65 :offset-x 1 :offset-y 0 + :advance-x 11 :image (rl/Image {})})) + (set (at glyphs 1) (rl/GlyphInfo {:value 66 :offset-x 2 :offset-y 0 + :advance-x 13 :image (rl/Image {})})) + (set (at glyphs 2) (rl/GlyphInfo {:value 67 :offset-x 3 :offset-y 0 + :advance-x 0 :image (rl/Image {})}))) + +(defn show-bool [name string b bool] + (print-str name) (print-str " ") + (print-line (if b "yes" "no"))) + +(defn show-i [name string v i32] + (print-str name) (print-str " ") (print-i64 (i64 v)) (newline)) + +(defn show-v [name string v rl/Vector2] + (print-str name) + (print-str " ") (print-f64 (f64 (.x v))) + (print-str " ") (print-f64 (f64 (.y v))) + (newline)) + +(defn show-rect [name string r rl/Rectangle] + (print-str name) + (print-str " ") (print-f64 (f64 (.x r))) + (print-str " ") (print-f64 (f64 (.y r))) + (print-str " ") (print-f64 (f64 (.width r))) + (print-str " ") (print-f64 (f64 (.height r))) + (newline)) + +(defn main [] i32 + (rl/set-trace-log-level :warning) + (build-glyphs) + + ;; The font. `texture` is a lie in every field but `id`, and the id is 1 for + ;; the reason in the header note: at 0, raylib refuses to measure anything. + ;; + ;; It is built here rather than by a helper because a `defn` cannot say it + ;; returns one: the parser decides whether the form after a parameter list + ;; is a return type or the first body expression by looking the name up in + ;; the set of types THIS FILE declares, and a package's types are not in it + ;; — imports are resolved after parsing. `(defn the-font [] rl/Font ...)` + ;; therefore parses rl/Font as an expression and fails with "unknown name". + ;; That is a parser limit and not something this file wanted; it applies to + ;; rl/Vector2 just as much as to rl/Font. + (let [f (rl/Font {:base-size 10 :glyph-count 3 :glyph-padding 2 + :texture (rl/Texture2D {:id 1 :width 32 :height 16 + :mipmaps 1 :format 7}) + :recs (addr (at glyph-recs 0)) + :glyphs (addr (at glyphs 0))})] + ;; font-valid? reads the texture id and both array pointers, so it is a + ;; null check on the three things everything below dereferences. + (show-bool "valid" (rl/font-valid? f)) + + ;; ── The search ────────────────────────────────────────────────────── + ;; + ;; A linear walk of `glyph-count` entries comparing `value`. Three hits at + ;; three different indices, and one miss: 'Z' is not in the font and + ;; raylib answers index 0 rather than running off the end. Shrink + ;; glyph-count — or land the field somewhere else — and 'C' misses too. + (show-i "index A" (rl/get-glyph-index f 65)) + (show-i "index B" (rl/get-glyph-index f 66)) + (show-i "index C" (rl/get-glyph-index f 67)) + (show-i "index Z" (rl/get-glyph-index f 90)) + + ;; ── Reading back through the two pointers ─────────────────────────── + ;; + ;; The atlas rectangle for B is the second entry of `recs`, and the glyph + ;; for C is the third of `glyphs`. Both are indexed by the search above, + ;; so an exchange of the two pointers is a Rectangle read as a GlyphInfo. + (show-rect "atlas B" (rl/get-glyph-atlas-rec f 66)) + (let [g (rl/get-glyph-info f 67)] + (show-i "glyph C value" (.value g)) + (show-i "glyph C offset" (.offset-x g)) + (show-i "glyph C advance" (.advance-x g))) + + ;; ── Measuring, which is where the arithmetic is ───────────────────── + ;; + ;; "AB" is 11 + 13 = 24 wide and base-size tall, at scale 1. + (show-v "measure AB" (rl/measure-text-ex f "AB" 10.0 0.0)) + + ;; "ABC" adds C, whose advance is 0, so raylib falls back to the atlas + ;; width plus the offset: 9 + 3 = 12, for 36. This line and the one above + ;; disagree by exactly the amount that proves the fallback branch ran, and + ;; that branch is the only thing in the file that reads Rectangle.width + ;; out of the recs array. + (show-v "measure ABC" (rl/measure-text-ex f "ABC" 10.0 0.0)) + + ;; The same string at twice the size and a spacing of 3. Scale is 20/10, + ;; so 24 doubles to 48, and spacing is added once per gap and not once per + ;; glyph: 48 + 3 = 51. Exchange base-size and glyph-count and the scale + ;; becomes 20/3 and the search bound becomes 10 — both wrong, in different + ;; directions, on the same line. + (show-v "measure AB big" (rl/measure-text-ex f "AB" 20.0 3.0)) + + ;; One glyph, so there is no gap for the spacing to land in: 11 at scale + ;; 1, and the 3 does not appear. Without this line a spacing added per + ;; glyph rather than per gap would pass everything above. + (show-v "measure A spaced" (rl/measure-text-ex f "A" 10.0 3.0))) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 85a45e3..1dc4b9c 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -353,6 +353,121 @@ let () = else print_endline "acceptance: skipping the raylib Image case (no libraylib)"; + (* raylib's Wave family, headless — and the first claim to make about it + is that it exists. The received wisdom in this repository was that + audio needs a device and so cannot be in this table at all. That is + true of Sound, Music and AudioStream, every one of which is a handle + the miniaudio mixer owns, and false of Wave: samples in RAM and four + integers describing them, with copy, crop, reformat, export, load and + decode all running on the CPU. So it gets the Image family's treatment. + + Three shapes, and they pin different things. + + wave-format is the scalars-in/fields-out case gen-image-color is: three + plain integers go in and all four u32 fields come out, with + frame-count *computed* from the ratio of the sample rates — 16, from + eight frames at twice the rate, and no argument named it. Eight bits + rather than sixteen in that call so sample-size cannot be confused + with the frame count it would otherwise equal. + + export-wave then load-wave is external ground truth, the PNG argument + transposed: dr_wav writes the header from three fields and the payload + length from the fourth, and reads all four back, agreeing with itself + rather than with Flan's field order. + + And the decoded samples are the axis discriminator this section needed. + load-wave-samples answers a (Ptr f32), which Flan cannot index — [at] + takes an array, a slice or a string — so the program crops to a single + frame first and dereferences sample 0. That detour is what makes the + case strong rather than weak: raylib's crop offset is init-frame times + channels times sample-size over 8, so asking for frame 3 and getting + +3000 pins sample-size against channels. Exchange those two and the + crop lands two bytes off and the sample is a different number, not the + same one mirrored, which is the failure mode axis-aligned geometry + could never produce. + + Verified red by permuting the Wave defstruct three ways: frame-count + with sample-rate (cropped reads "8 4 16 1" and reformatted + "16000 16000000 8 2"), sample-size with channels (every frame read + turns to "no"), and data moved to the front (the run dies after two + lines). *) + let raylib_audio_out = + "valid yes\n\ + source 8 8000 16 1\n\ + cropped 4 8000 16 1\n\ + reformatted 16 16000 8 2\n\ + frame 1 is +1000 yes\n\ + frame 3 is +3000 yes\n\ + frame 4 is -3000 yes\n\ + frame 0 is zero yes\n\ + exported yes\n\ + loaded valid yes\n\ + loaded 8 8000 16 1\n\ + loaded frame 1 is +1000 yes\n\ + loaded frame 4 is -3000 yes\n" + in + if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin + outputs "raylib audio, headless" "programs/raylib-audio.flan" + raylib_audio_out; + outputs ~opt:"-O0" "raylib audio, headless, -O0" "programs/raylib-audio.flan" + raylib_audio_out + end + else + print_endline "acceptance: skipping the raylib Wave case (no libraylib)"; + + (* raylib's Font family, headless, which the package refused to bind at + all until now. The stated reason was that a Font drags in two more + aggregates and two owned arrays "for something with no headless test at + the end of it". The generator takes all of it unchanged — a struct held + by value is emitted after what it contains, one held by pointer is + forward-declared — and the test turned out to exist. + + What makes it exist is that the program does not ASK raylib for a font. + Every call that makes one needs a window or a TTF and a GL context. + So it builds one out of Flan arrays, field by field, and hands it over + to be computed with: three glyphs, three atlas rectangles, a base size + of 10 and a texture that is a lie in every field but [id]. Text + measuring is pure arithmetic over exactly those fields, so this is + scalars in and numbers out with no raylib-produced struct anywhere for + a permutation to cancel against. + + The one raylib trap worth recording: MeasureTextEx returns (0,0) + immediately when font.texture.id is 0. The hand-built font claims an id + of 1, and that guard is what pins where the Texture2D sits inside the + Font — land it elsewhere and every measurement collapses to zero. + + Glyph C carries an advance of 0 deliberately, which sends raylib down + its other branch: the atlas rectangle's width plus the glyph's offset, + 9 + 3 = 12, which is why "ABC" is 36 and "AB" is 24. And "A" at a + spacing of 3 measures 11 and not 14, because spacing is added per gap + and not per glyph — without that line, a wrapper that added it per + glyph would pass everything else. + + Verified red by four permutations: base-size with glyph-count (the + measurements become 80, 120, 163 and 36.6667), offset-x with advance-x + (3, 6, 9, 1), the recs and glyphs pointers (floats in the 1e9 range and + a garbage atlas rectangle), and moving [texture] to the end of the + Font (the run dies after the first line). Two of the four were a crash + rather than a wrong number, which still counts. *) + let raylib_font_out = + "valid yes\n\ + index A 0\nindex B 1\nindex C 2\nindex Z 0\n\ + atlas B 5 0 7 10\n\ + glyph C value 67\nglyph C offset 3\nglyph C advance 0\n\ + measure AB 24 10\n\ + measure ABC 36 10\n\ + measure AB big 51 20\n\ + measure A spaced 11 10\n" + in + if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin + outputs "raylib fonts, headless" "programs/raylib-font.flan" + raylib_font_out; + outputs ~opt:"-O0" "raylib fonts, headless, -O0" "programs/raylib-font.flan" + raylib_font_out + end + else + print_endline "acceptance: skipping the raylib Font case (no libraylib)"; + (* Again at -O0. Everything above runs through mem2reg, which launders a sloppy alloca; -O0 tests the IR actually emitted, so a disagreement between the two points at undefined behaviour rather than a typo. *) From 245ad60fd8fcce0c9ac1d78dc88d83e651bcaa8c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:51:08 +0700 Subject: [PATCH 3/4] Give the un-assertable bindings somewhere to be looked at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding nothing calls is a binding nothing checks, and that was already true of key-released? and mouse-button-pressed? before this lane added sixty more. Audio, render textures, gamepads, touch and gestures cannot be in the acceptance table — a sound needs a device, a framebuffer needs a GL context, and with no pad attached every gamepad predicate answers what a wrapper with its arguments crossed would — so they go here, where running the program is the check. Each read-out is built to be asymmetric: the world is drawn through a render texture with the negative source height raylib's bottom-up framebuffer requires, so a missing flip is an upside-down world rather than a subtle one; the stick dot is offset by x and y separately; the two trigger bars are different lengths. The tone is generated in Flan rather than shipped as an asset, which is also what gives export-wave and load-music-stream a call site outside a test. --- sand.flan | 299 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 293 insertions(+), 6 deletions(-) diff --git a/sand.flan b/sand.flan index 90f5d42..6d46dbb 100644 --- a/sand.flan +++ b/sand.flan @@ -207,6 +207,136 @@ (rl/draw-texture-ex brush-mirrored (rl/Vector2 {:x 110.0 :y 46.0}) 0.0 2.0 rl/white))) +;; ── Sound ────────────────────────────────────────────────────────── +;; +;; Nothing in this section can be in the acceptance table and the reason is +;; sharper than "it needs a GL context": a Sound is a buffer the miniaudio +;; mixer owns, so it does not exist until init-audio-device has found a +;; device, and a machine with no sound server gets a zeroed struct and a +;; warning. What CAN be asserted headlessly is the Wave the sound is made +;; from — test/programs/raylib-audio.flan does exactly that — so the split +;; here is real and not an excuse: the samples are checked, the playing is +;; only listened to. +;; +;; The tone is generated rather than loaded because a .wav in the repository +;; would be an asset to maintain for one plink, and because generating it is +;; what gives load-sound-from-wave and export-wave a call site that is not a +;; test. + +(defconst tone-rate 22050) +(defconst tone-frames 4410) ; 0.2 s +(defconst tone-path "/tmp/flan-sand-tone.wav") + +;; Little-endian signed 16-bit PCM, two bytes per frame, written as bytes +;; because that is what a Wave's `data` is: the element width is `sample-size`, +;; a run-time number, and no Flan type says what the buffer holds. +(defvar tone-pcm [8820 u8]) + +(defn write-sample [i i32 v i32] + (set (at tone-pcm (* i 2)) (u8 (bit-and v 255))) + (set (at tone-pcm (+ (* i 2) 1)) (u8 (bit-and (>> v 8) 255)))) + +;; A square wave that decays to nothing over its length, which is the +;; cheapest thing that sounds like a plink rather than a click. `period` is +;; the half-period in frames, so a smaller one is a higher note. +(defn build-tone [period i32] + (dotimes [i tone-frames] + (let [amp (/ (* 9000 (- tone-frames i)) tone-frames)] + (write-sample i (if (= 0 (% (/ i period) 2)) amp (- 0 amp)))))) + +(defvar audio-ok bool) +(defvar tone rl/Sound) +(defvar tone-ok bool) +(defvar music rl/Music) +(defvar music-ok bool) +(defvar music-on bool) + +(defn start-audio [] + (rl/init-audio-device) + (set audio-ok (rl/audio-device-ready?)) + (unless audio-ok + (print-line "sand: no audio device — the grains are silent")) + (build-tone 40) + (let [w (rl/Wave {:frame-count (u32 tone-frames) :sample-rate (u32 tone-rate) + :sample-size 16 :channels 1 + :data (addr (at tone-pcm 0))})] + (when audio-ok + (set tone (rl/load-sound-from-wave w)) + (set tone-ok (rl/sound-valid? tone)) + (rl/set-sound-volume tone 0.25) + (rl/set-sound-pan tone 0.5) + ;; The same samples out to a file and straight back in as a stream, so + ;; the Music side has a call site without an asset in the repository. + ;; A Sound is resident and a Music is decoded as it plays, which is the + ;; whole difference, and update-music-stream in the loop below is the + ;; visible consequence of it. + (when (rl/export-wave w tone-path) + (set music (rl/load-music-stream tone-path)) + (set music-ok (rl/music-valid? music)) + (when music-ok + (set (.looping music) true) + (rl/set-music-volume music 0.15) + (rl/set-music-pitch music 0.5))))) + (rl/set-master-volume 0.6)) + +(defn stop-audio [] + (when music-ok (rl/unload-music-stream music)) + (when tone-ok (rl/unload-sound tone)) + (rl/close-audio-device)) + +;; Every plink goes through here, so a silent build — no device, or a wave +;; that would not load — is one branch and not a guard at every call site. +(defn plink [pitch f32] + (when tone-ok + (rl/set-sound-pitch tone pitch) + (rl/play-sound tone))) + +(defn toggle-music [] + (when music-ok + (set music-on (not music-on)) + (if music-on + (rl/play-music-stream music) + (rl/pause-music-stream music)))) + +;; ── The scene, off-screen ─────────────────────────────────────────── +;; +;; The world is drawn into a render texture and the render texture is drawn to +;; the screen, which is what a post-process pass or a pixel-perfect upscale +;; would hang off. There is nothing to assert here either — LoadRenderTexture +;; makes a GL framebuffer object and answers an id of 0 with no context — so +;; the fallback below is not defensive padding, it is what keeps the program +;; honest when the framebuffer is not there. +;; +;; The source rectangle's height is NEGATIVE on purpose. raylib renders into a +;; framebuffer bottom-up, so drawing the result the right way up needs the +;; flip, and getting it wrong is a world that is upside down rather than an +;; error. + +(defvar scene rl/RenderTexture2D) +(defvar scene-ok bool) + +(defn load-scene [] + (set scene (rl/load-render-texture screen-width screen-height)) + (set scene-ok (rl/render-texture-valid? scene)) + (unless scene-ok + (print-line "sand: no render texture — drawing straight to the screen"))) + +;; ── The font ──────────────────────────────────────────────────────── +;; +;; get-font-default needs a window: the default font is loaded as part of +;; init-window and LoadFontDefault is not exported, which is the same fact +;; that makes the plain measure-text answer 0 headless. The Font family IS +;; assertable — test/programs/raylib-font.flan builds one by hand and has +;; raylib measure with it — so what is left here is the drawing, and the +;; drawing is looked at. + +(defvar hud-font rl/Font) +(defvar hud-font-ok bool) + +(defn load-hud-font [] + (set hud-font (rl/get-font-default)) + (set hud-font-ok (rl/font-valid? hud-font))) + ;; ── The view ──────────────────────────────────────────────────────── ;; ;; begin-mode-2d and end-mode-2d were bound along with Camera2D and then @@ -279,8 +409,27 @@ (defn game-update [] (when (rl/key-pressed? :r) (clear-grid)) (move-view) + ;; key-released? and mouse-button-pressed? were bound and called by nothing + ;; at all, which is the same as not having bound them. They are the two + ;; halves nothing else here uses: space cycles the colour when it comes back + ;; UP, and the right button resets the view the instant it goes DOWN, so the + ;; two edges are told apart by eye rather than only in the source. + (when (rl/key-released? :space) (next-color) (plink 1.4)) + (when (rl/mouse-button-pressed? :right) (reset-view) (plink 0.6)) + (when (rl/mouse-button-pressed? :left) (plink 1.0)) + (when (rl/key-pressed? :m) (toggle-music)) + ;; The pad, when there is one. Pressed and released are separate events here + ;; too, for the same reason. + (when (rl/gamepad-available? 0) + (when (rl/gamepad-button-pressed? 0 :right-face-down) (plink 1.2)) + (when (rl/gamepad-button-released? 0 :right-face-down) (next-color)) + (when (rl/gamepad-button-down? 0 :middle-right) (clear-grid))) (when (rl/mouse-button-down? :left) (paint)) (when (rl/mouse-button-released? :left) (next-color)) + ;; Once a frame, every frame, or the stream runs dry and the music stops + ;; without saying anything. This is the whole difference between a Music and + ;; a Sound. + (when music-on (rl/update-music-stream music)) (step)) (defn draw-grid [] @@ -341,11 +490,20 @@ ;; visible rather than merely different. (defn draw-hud [] (let [title "SAND" - keys "arrows pan , . zoom 0 reset r clear" + keys "arrows pan , . zoom 0 reset r clear space colour m music" ;; measure-text is what sizes the panel, so the box fits the string ;; rather than a number somebody guessed. Headless it answers 0 for ;; everything, which is why it is not in the acceptance table. - w (max (rl/measure-text title 30) (rl/measure-text keys 20)) + ;; + ;; The title is measured with the Font form instead, which takes a + ;; float size and a spacing the integer form has no way to express — + ;; and then drawn with the matching draw-text-ex, so the box and the + ;; letters agree. That agreement is the check: measure with one and + ;; draw with the other and the panel is visibly the wrong width. + title-w (if hud-font-ok + (i32 (.x (rl/measure-text-ex hud-font title 30.0 4.0))) + (rl/measure-text title 30)) + w (max title-w (rl/measure-text keys 20)) h 72 x 24 y (- (rl/get-screen-height) (+ h 24)) @@ -357,7 +515,10 @@ (rl/draw-rectangle-rounded-lines panel (f32 0.2) 8 (rl/get-color 0x404060FF)) (rl/draw-rectangle-rounded-lines-ex panel (f32 0.2) 8 (f32 2.0) (rl/get-color 0x6060A0FF)) - (rl/draw-text title x y 30 rl/white) + (if hud-font-ok + (rl/draw-text-ex hud-font title (rl/Vector2 {:x (f32 x) :y (f32 y)}) + 30.0 4.0 rl/white) + (rl/draw-text title x y 30 rl/white)) (rl/draw-text keys x (+ y 40) 20 (rl/get-color 0xA0A0B0FF)) ;; The palette, along the bottom right. The selected colour is the one @@ -371,6 +532,18 @@ (when (= i current-color) (rl/draw-circle-lines cx sh (f32 22.0) rl/white)))) + ;; The selected index as a digit, drawn one codepoint at a time. There + ;; is no string formatting in the language yet, so this is the only way + ;; a number reaches the screen at all — and get-glyph-index is what says + ;; whether the default font has the digit before it is asked for. + (when hud-font-ok + (let [cp (+ 48 current-color)] + (when (> (rl/get-glyph-index hud-font cp) 0) + (rl/draw-text-codepoint hud-font cp + (rl/Vector2 {:x (f32 (- sw 24)) + :y (f32 (- sh 96))}) + 30.0 rl/white)))) + ;; A zoom read-out with no number in it, because there is no string ;; formatting yet: the bar's length is the zoom. draw-rectangle-rec, ;; draw-rectangle-v and draw-rectangle-lines are the three remaining @@ -406,16 +579,118 @@ (f32 30.0) (f32 34.0) spin (+ spin (f32 270.0)) 32 rl/white))))) -(defn game-draw [] - (rl/clear-background rl/black) + +;; ── Gamepads, touch and gestures ──────────────────────────────────── +;; +;; None of this can be asserted anywhere, and the reason is worth stating +;; rather than assuming. With no pad attached, gamepad-available? is false, +;; every button predicate is false and every axis reads 0.0 — which is exactly +;; what a wrapper with its two int arguments exchanged would report. Touch and +;; gestures are the same: they are fed by raylib's own event polling inside a +;; frame loop, so they answer nothing at all headless. A test would be +;; asserting that two zeroes are equal. +;; +;; So they are drawn, and the only check they get is that moving a stick moves +;; the marker. Every read-out below is asymmetric on purpose — the stick dot +;; is offset by x and y separately, and the trigger bars are different lengths +;; — so a crossed wrapper is visible rather than merely different. +(defn draw-input-state [] + (let [ox (f32 200.0) + oy (f32 90.0) + r (f32 34.0)] + (when (rl/gamepad-available? 0) + ;; The ring only appears when raylib says a pad is there, which is what + ;; separates "centred stick" from "no pad" — both of which are a dot in + ;; the middle otherwise. + (rl/draw-circle-lines (i32 ox) (i32 oy) r (rl/get-color 0x6060A0FF)) + (let [p (rl/Vector2 {:x (+ ox (* (rl/get-gamepad-axis-movement 0 :left-x) r)) + :y (+ oy (* (rl/get-gamepad-axis-movement 0 :left-y) r))})] + (rl/draw-circle-v p (f32 5.0) rl/white)) + ;; The two triggers as bars of different lengths, so exchanging them is + ;; visible. They rest at -1 and not at 0, which raylib does not + ;; normalise and neither does this — hence the +1. + (let [lt (+ (f32 1.0) (rl/get-gamepad-axis-movement 0 :left-trigger)) + rt (+ (f32 1.0) (rl/get-gamepad-axis-movement 0 :right-trigger))] + (rl/draw-rectangle-v (rl/Vector2 {:x (+ ox (f32 46.0)) :y (- oy (f32 12.0))}) + (rl/Vector2 {:x (* lt (f32 30.0)) :y (f32 8.0)}) + (rl/get-color 0x8080C0FF)) + (rl/draw-rectangle-v (rl/Vector2 {:x (+ ox (f32 46.0)) :y (+ oy (f32 4.0))}) + (rl/Vector2 {:x (* rt (f32 50.0)) :y (f32 8.0)}) + (rl/get-color 0x8080C0FF))) + ;; One pip per axis the pad reports, and one per face button that is + ;; NOT up — gamepad-button-up? rather than -down? so the negative form + ;; has a call site of its own. + (dotimes [i (rl/get-gamepad-axis-count 0)] + (rl/draw-pixel (+ (i32 ox) (* i 4)) (+ (i32 oy) 44) rl/white)) + (unless (rl/gamepad-button-up? 0 :right-face-down) + (rl/draw-circle (+ (i32 ox) 110) (i32 oy) (f32 6.0) rl/white)) + ;; -1 when nothing is pressed, which is why this is an i32 and not a + ;; GamepadButton: the answer is outside the enum. + (when (>= (rl/get-gamepad-button-pressed) 0) + (rl/draw-circle (+ (i32 ox) 130) (i32 oy) (f32 6.0) + (rl/get-color 0xFFC000FF)))) + + ;; Touch. On a desktop the count is 0 but point 0 still follows the + ;; mouse, so the ring below is what says a real touchscreen is there. + (dotimes [i (rl/get-touch-point-count)] + (rl/draw-circle-lines-v (rl/get-touch-position i) (f32 18.0) + (rl/get-color 0xA0A0FFFF)) + (rl/draw-pixel (rl/get-touch-x) (rl/get-touch-y) rl/white) + (rl/draw-pixel-v (rl/Vector2 {:x (f32 (rl/get-touch-point-id i)) + :y (f32 4.0)}) + rl/white)) + + ;; And the gesture, if any: a bar as long as the hold has lasted, and the + ;; drag vector drawn from the centre of the ring. + (unless (= (rl/get-gesture-detected) :none) + (rl/draw-rectangle (i32 ox) (+ (i32 oy) 54) + (i32 (* (f32 60.0) (rl/get-gesture-hold-duration))) + 8 rl/white) + (when (rl/gesture-detected? :drag) + (let [d (rl/get-gesture-drag-vector)] + (rl/draw-line-v (rl/Vector2 {:x ox :y oy}) + (rl/Vector2 {:x (+ ox (* (.x d) (f32 200.0))) + :y (+ oy (* (.y d) (f32 200.0)))}) + (rl/get-color 0xFFC000FF)))) + (when (rl/gesture-detected? :pinch-in) + (let [q (rl/get-gesture-pinch-vector)] + (rl/draw-circle-v (rl/Vector2 {:x (+ ox (* (.x q) (f32 200.0))) + :y (+ oy (* (.y q) (f32 200.0)))}) + (f32 4.0) rl/white)))))) + +(defn draw-world [] ;; Everything between these two is in world space and moves with the camera. (rl/begin-mode-2d view) (draw-grid) (draw-world-cursor) - (rl/end-mode-2d) + (rl/end-mode-2d)) + +(defn game-draw [] + ;; The world goes into the render texture first, if there is one. Note the + ;; clear inside the texture mode: the framebuffer keeps last frame's pixels + ;; otherwise, which looks like a trail and not like a bug. + (when scene-ok + (rl/begin-texture-mode scene) + (rl/clear-background rl/black) + (draw-world) + (rl/end-texture-mode)) + (rl/clear-background rl/black) + ;; Then the texture, upside down on purpose: raylib renders into a + ;; framebuffer bottom-up, so a negative source height is the correction and + ;; not a trick. Without a framebuffer the world is drawn straight to the + ;; screen and nothing else changes. + (if scene-ok + (rl/draw-texture-rec (.texture scene) + (rl/Rectangle {:x 0.0 :y 0.0 + :width (f32 screen-width) + :height (f32 (- 0 screen-height))}) + (rl/Vector2 {:x 0.0 :y 0.0}) + rl/white) + (draw-world)) ;; And everything after it is in screen pixels again. (draw-brush) (draw-hud) + (draw-input-state) (rl/draw-fps 20 20)) (defn main [] @@ -430,6 +705,18 @@ (load-brush) (defer (rl/unload-texture brush)) (defer (rl/unload-texture brush-mirrored)) + ;; The same rule for the framebuffer and the default font, and a third one + ;; for the mixer: the audio device is its own subsystem and does not come + ;; with the window, so it is opened and closed on its own. + (load-scene) + (defer (rl/unload-render-texture scene)) + (load-hud-font) + (start-audio) + (defer (stop-audio)) + ;; Gestures are off until something asks for them. Everything, because the + ;; read-out draws whichever one arrives, and because the parameter is a set + ;; of flags rather than one member — see the note on the Gesture enum. + (rl/set-gestures-enabled rl/gesture-all) ;; The dev agent listens on a socket for redefinitions and hands them over; ;; (agent/poll) below is where they are installed. Building without --dev is ;; fine — nothing has cells to install into, so a module is refused on the From a53a3603ee2496a39835b7ef605921ddc66cdaaa Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:55:06 +0700 Subject: [PATCH 4/4] Say what each case does not catch, not only what it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims in these comments were stronger than the permutation runs behind them. The WAV round trip catches sample-size against channels and leaves frame-count against sample-rate entirely green — the crop and the reformat are what catch that pair, and a reader who trusted the round trip would drop exactly the wrong case. The font file listed what it pins and never said that glyph-padding, offset-y and three of each atlas rectangle's four fields are read by nothing here at all. Two more permutations run and recorded while fixing it: GlyphInfo's image moved to the front, which shifts the four ints 24 bytes and collapses the glyph search, and Rectangle's x with width, which moves "measure ABC" to 39 and confirms the advance-0 fallback is the only thing reading a width out of the recs array. --- test/programs/raylib-audio.flan | 21 ++++++++++++----- test/programs/raylib-font.flan | 17 ++++++++++++-- test/test_acceptance.ml | 41 +++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/test/programs/raylib-audio.flan b/test/programs/raylib-audio.flan index d77b87f..7e2953a 100644 --- a/test/programs/raylib-audio.flan +++ b/test/programs/raylib-audio.flan @@ -23,12 +23,17 @@ ;; is the strongest shape a headless FFI test has. ;; ;; 2. **The file, as external ground truth.** export-wave writes a RIFF -;; header carrying sample-rate, sample-size and channels, and a payload -;; whose length says frame-count; load-wave reads that header back. Both -;; ends are dr_wav's and agree with each other, not with whatever field -;; order Flan believes in — the same reason the PNG round trip in -;; raylib-image.flan is not the symmetric trap a store-and-return check -;; is. +;; header carrying sample-rate, sample-size and channels; load-wave reads +;; it back. Both ends are dr_wav's and agree with each other, not with +;; whatever field order Flan believes in — the same reason the PNG round +;; trip in raylib-image.flan is not the symmetric trap a store-and-return +;; check is. +;; +;; Its reach is narrower than it looks and the narrowness is worth +;; knowing: exchanging sample-size and channels turns the loaded line +;; into "8 8000 16 16", while exchanging frame-count and sample-rate +;; leaves every line of the round trip untouched. What catches THAT pair +;; is the crop and the reformat, above. Neither claim covers the other. ;; ;; 3. **`data` as a pointer, and the bytes behind it.** load-wave-samples ;; decodes through the buffer. Move `data` up among the integers and @@ -181,6 +186,10 @@ ;; ends are external to Flan and agree with each other, so this is not the ;; round trip that passes for any field order — it is the PNG argument, ;; for audio. It also crosses a path as ptr+len. + ;; + ;; It does not catch everything, and the header note says which: exchange + ;; frame-count and sample-rate and all three lines below stay green. The + ;; crop and the reformat are what go red for that pair. (show-bool "exported" (rl/export-wave src wav-path)) (let [back (rl/load-wave wav-path)] (show-bool "loaded valid" (rl/wave-valid? back)) diff --git a/test/programs/raylib-font.flan b/test/programs/raylib-font.flan index 4b2c719..2f35eb2 100644 --- a/test/programs/raylib-font.flan +++ b/test/programs/raylib-font.flan @@ -40,6 +40,15 @@ ;; texture — and if the texture landed anywhere else in the struct the id ;; would read 0 and every measurement below would collapse to zero. ;; +;; And what is NOT pinned, which matters as much: `glyph-padding` is read by +;; nothing raylib computes on the CPU, `offset-y` only moves a glyph when one +;; is actually drawn, and of each atlas rectangle only `width` is ever looked +;; at — `x`, `y` and `height` come back from get-glyph-atlas-rec exactly as +;; they were stored, which is the symmetric trap and proves nothing. Those +;; four rest on raylib's header and on sand.flan looking right. This is the +;; same limit raylib-ffi.flan records for Texture2D's width, height and +;; mipmaps, and it is written down for the same reason. +;; ;; The numbers are chosen so that no two are equal: base size 10, glyph count ;; 3, padding 2, advances 11 and 13, offsets 1, 2 and 3, atlas widths 5, 7 ;; and 9. Everything prints exactly — no trigonometry, so unlike the rotated @@ -52,7 +61,10 @@ ;; The glyphs themselves. `image` is raylib's own pixels for the glyph and is ;; left zeroed — it is present so the four ints in front of it are at the -;; right offsets and so a GlyphInfo is 40 bytes rather than 16. +;; right offsets and so a GlyphInfo is 40 bytes rather than 16. That claim is +;; checked: moving `image` to the front of the defstruct shifts the four ints +;; by 24 bytes, and the glyph search then finds nothing — every index reads 0 +;; and glyph C answers with A's numbers. (defvar glyphs [3 rl/GlyphInfo]) (defn build-glyphs [] @@ -144,7 +156,8 @@ ;; width plus the offset: 9 + 3 = 12, for 36. This line and the one above ;; disagree by exactly the amount that proves the fallback branch ran, and ;; that branch is the only thing in the file that reads Rectangle.width - ;; out of the recs array. + ;; out of the recs array — exchange x and width in the Rectangle defstruct + ;; and this line reads 39 while the one above stays 24. (show-v "measure ABC" (rl/measure-text-ex f "ABC" 10.0 0.0)) ;; The same string at twice the size and a spacing of 3. Scale is 20/10, diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 1dc4b9c..0c34439 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -371,9 +371,13 @@ let () = with the frame count it would otherwise equal. export-wave then load-wave is external ground truth, the PNG argument - transposed: dr_wav writes the header from three fields and the payload - length from the fourth, and reads all four back, agreeing with itself - rather than with Flan's field order. + transposed: dr_wav writes the header from three fields and reads them + back, agreeing with itself rather than with Flan's field order. Be + precise about its reach, because it is narrower than it looks — it + catches sample-size against channels (the "loaded" line reads + "8 8000 16 16" when those two are exchanged) and NOT frame-count + against sample-rate, which leaves every line of the round trip green. + The crop and the reformat are what catch that pair. And the decoded samples are the axis discriminator this section needed. load-wave-samples answers a (Ptr f32), which Flan cannot index — [at] @@ -388,9 +392,10 @@ let () = Verified red by permuting the Wave defstruct three ways: frame-count with sample-rate (cropped reads "8 4 16 1" and reformatted - "16000 16000000 8 2"), sample-size with channels (every frame read - turns to "no"), and data moved to the front (the run dies after two - lines). *) + "16000 16000000 8 2", while the file round trip stays green — see + above), sample-size with channels (every frame read turns to "no" and + the loaded line reads "8 8000 16 16"), and data moved to the front + (the run dies after two lines). *) let raylib_audio_out = "valid yes\n\ source 8 8000 16 1\n\ @@ -443,12 +448,24 @@ let () = and not per glyph — without that line, a wrapper that added it per glyph would pass everything else. - Verified red by four permutations: base-size with glyph-count (the - measurements become 80, 120, 163 and 36.6667), offset-x with advance-x - (3, 6, 9, 1), the recs and glyphs pointers (floats in the 1e9 range and - a garbage atlas rectangle), and moving [texture] to the end of the - Font (the run dies after the first line). Two of the four were a crash - rather than a wrong number, which still counts. *) + Verified red by six permutations. In Font: base-size with glyph-count + (the measurements become 80, 120, 163 and 36.6667), the recs and glyphs + pointers (floats in the 1e9 range and a garbage atlas rectangle), and + [texture] moved to the end (the run dies after the first line). In + GlyphInfo: offset-x with advance-x (3, 6, 9, 1), and [image] moved to + the FRONT, which shifts the four ints by 24 bytes — the glyph search + collapses, every index reads 0 and glyph C answers with A's fields. + In Rectangle: x with width, which moves "measure ABC" to 39 and leaves + "measure AB" at 24, since only the advance-0 fallback reads a width out + of the recs array. Two of the six were a crash rather than a wrong + number, which still counts. + + What this case does NOT pin, said here for the same reason the + Texture2D notes above say it: glyph-padding is read by nothing raylib + computes on the CPU, offset-y only moves a glyph when it is drawn, and + of each atlas rectangle only `width` is ever looked at. Those four + fields rest on the header agreeing with raylib's and on sand.flan + looking right, and on nothing else. *) let raylib_font_out = "valid yes\n\ index A 0\nindex B 1\nindex C 2\nindex Z 0\n\