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