Assert audio and fonts headlessly, which both were said to be impossible

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.
This commit is contained in:
Joseph Ferano 2026-09-12 03:45:41 +07:00
parent 826c62a1e9
commit 34b6543e58
3 changed files with 469 additions and 0 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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. *)