From 07d068d2976b90489721c51cc57d0844ec9ebfd1 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 17:52:04 +0700 Subject: [PATCH 1/4] A struct that comes back unchanged proves nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texture2D and Rectangle are the two structs the texture calls need, and they are the ones whose layout can be silently wrong: five 4-byte fields in a row, and four floats in a row, so a permutation still reads as plausible numbers everywhere. The obvious test — hand raylib a struct, read it back, compare — is worthless here, and I only found that out by trying it. Storing and returning is symmetric: swap two fields in the Flan defstruct and the round trip still agrees with itself, because C writes and reads the same wrong slots. That test passes whatever the layout is, which is the kind of test this project would rather not have at all. So the headless case uses the two things raylib computes from the fields without a GPU. GetCollisionRec turns (0,0,10,4) and (6,1,10,10) into (6,1,4,3), four different numbers each derived from a different pair of fields, and no permutation of Rectangle survives it. SetShapesTexture keeps a Texture2D without touching GL and substitutes 1 1 1 1 7 when the id is zero, so a zero id pins the first field, the 7 pins the last, and a zero width stored rather than substituted is what stops that pair from passing with id and width swapped. Each of those was checked by permuting the defstruct and watching the case fail. What is left unpinned is width, height and mipmaps against each other; nothing raylib does without a GL context reads them. That is stated in the program rather than papered over, because the alternative is a case that looks like it covers them. set-shapes-texture, get-shapes-texture, get-shapes-texture-rectangle and get-collision-rec are real bindings, not test scaffolding — they are bound here because they are also the only pure consumers of these two structs. --- test/programs/raylib-ffi.flan | 72 ++++++++++++++++++++++++++++++++--- test/test_acceptance.ml | 30 +++++++++++---- vendor/raylib/raylib.flan | 60 +++++++++++++++++++++++++++++ vendor/raylib/shim.c | 21 ++++++++++ 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/test/programs/raylib-ffi.flan b/test/programs/raylib-ffi.flan index ad0b719..f336fe7 100644 --- a/test/programs/raylib-ffi.flan +++ b/test/programs/raylib-ffi.flan @@ -1,12 +1,74 @@ (import rl "vendor:raylib") -;; No window: GetColor and the enums are pure, so this exercises the whole -;; boundary — struct out-pointer, keyword->enum, string ptr+len — headlessly. +;; The raylib boundary, headless. GetColor, the shapes texture and rectangle +;; intersection all need no window, so the whole crossing — a struct out of C +;; through an out-pointer, a struct into C through a pointer, a keyword +;; resolved against an enum — is exercised without a display. +;; +;; What is being checked is that a struct's FIELDS mean the same thing on both +;; sides. Note what does not check that: handing raylib a struct and reading it +;; back, because storing and returning is symmetric and a permuted layout +;; survives it unchanged. Every case below is asymmetric — raylib does +;; something to the fields that depends on which is which. + +(defn show-texture [t rl/Texture2D] + (print-i64 (i64 (.id t))) (newline) + (print-i64 (i64 (.width t))) (newline) + (print-i64 (i64 (.height t))) (newline) + (print-i64 (i64 (.mipmaps t))) (newline) + (print-i64 (i64 (.format t))) (newline)) + +(defn show-rect [r rl/Rectangle] + (print-f64 (f64 (.x r))) (newline) + (print-f64 (f64 (.y r))) (newline) + (print-f64 (f64 (.width r))) (newline) + (print-f64 (f64 (.height r))) (newline)) + (defn main [] i32 + (rl/set-trace-log-level :warning) + + ;; A Color is four bytes in RGBA order, so 0x11223344 is 17 34 51 68 and not + ;; the little-endian reading of the packed integer. An identity would pass a + ;; weaker test than this one. (let [c (rl/get-color 0x11223344)] (print-i64 (i64 (.r c))) (newline) (print-i64 (i64 (.g c))) (newline) (print-i64 (i64 (.b c))) (newline) - (print-i64 (i64 (.a c))) (newline) - (rl/set-trace-log-level :warning) - 0)) + (print-i64 (i64 (.a c))) (newline)) + + ;; Rectangle, pinned completely. The intersection of (0,0,10,4) and + ;; (6,1,10,10) is (6,1,4,3) — four different numbers, each derived from a + ;; different pair of fields, so swapping any two fields changes the answer. + (show-rect (rl/get-collision-rec (rl/Rectangle {:x 0.0 :y 0.0 :width 10.0 :height 4.0}) + (rl/Rectangle {:x 6.0 :y 1.0 :width 10.0 :height 10.0}))) + + ;; Texture2D, as far as a machine with no GPU can go. raylib keeps the + ;; shapes texture without touching GL, and substitutes a default when + ;; `texture.id`, `source.width` or `source.height` is zero — that guard is + ;; the only asymmetry a headless test gets. + (let [rect (rl/Rectangle {:x 3.5 :y 7.25 :width 11.5 :height 13.75})] + ;; (A) Valid, five distinct values: they come back, so the struct crosses + ;; intact in both directions and raylib stored it rather than defaulting. + (rl/set-shapes-texture (rl/Texture2D {:id 7 :width 13 :height 17 :mipmaps 2 :format 4}) rect) + (show-texture (rl/get-shapes-texture)) + (show-rect (rl/get-shapes-texture-rectangle)) + + ;; (B) id zero, everything else positive: the default 1 1 1 1 7 comes + ;; back. The 7 is the only distinct field in it, so this pins `format` as + ;; the last field, and the substitution happening at all pins `id` as the + ;; field the guard reads. + (rl/set-shapes-texture (rl/Texture2D {:id 0 :width 13 :height 17 :mipmaps 2 :format 4}) rect) + (show-texture (rl/get-shapes-texture)) + + ;; (C) width zero, id positive: still stored, because the guard does not + ;; look at the texture's width. Without this case, (B) would pass just as + ;; well with `id` and `width` swapped — the zero would land in the guarded + ;; slot either way. + ;; + ;; That is the limit of what is checkable here: nothing raylib computes + ;; without a GL context reads width, height or mipmaps, so their order + ;; among themselves is not pinned by this test. A swap there shows up as a + ;; visibly wrong sprite in the interactive run, and nowhere else. + (rl/set-shapes-texture (rl/Texture2D {:id 7 :width 0 :height 17 :mipmaps 2 :format 4}) rect) + (show-texture (rl/get-shapes-texture))) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 3670c0c..7e2466e 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -142,14 +142,30 @@ let () = text code end; - (* The raylib FFI, headless. GetColor and the enums need no window, so the - whole boundary is exercised without a display: a struct returned through - an out-pointer, a keyword resolved against an enum, and a Flan string - crossing as ptr+len. 0x11223344 comes back as four separate bytes, which - is the check that matters — a Color is not the little-endian reading of - the packed integer, so an identity would pass a weaker test. *) + (* The raylib FFI, headless. GetColor, rectangle intersection and the + shapes texture need no window, so the whole boundary is exercised + without a display: a struct out of C through an out-pointer, a struct + into C through a pointer, a keyword resolved against an enum, and a + Flan string crossing as ptr+len. + + Every case is asymmetric, which is the point. 0x11223344 comes back as + four separate bytes, so a Color is not the little-endian reading of the + packed integer. The intersection of (0,0,10,4) and (6,1,10,10) is + (6,1,4,3), four numbers from four different pairs of fields, so no + permutation of Rectangle survives it. And the shapes texture is stored + or replaced by 1 1 1 1 7 depending on which field is zero, which pins + Texture2D's id and format. Handing raylib a struct and reading it back + would have passed with any of those permuted — storing and returning is + symmetric. What the last case cannot pin, because nothing raylib + computes without a GL context reads them, is width, height and mipmaps + against each other. *) if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then - outputs "raylib ffi, headless" "programs/raylib-ffi.flan" "17\n34\n51\n68\n" + outputs "raylib ffi, headless" "programs/raylib-ffi.flan" + "17\n34\n51\n68\n\ + 6\n1\n4\n3\n\ + 7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\n\ + 1\n1\n1\n1\n7\n\ + 7\n0\n17\n2\n4\n" else print_endline "acceptance: skipping the raylib FFI case (no libraylib)"; diff --git a/vendor/raylib/raylib.flan b/vendor/raylib/raylib.flan index 0d6194d..9450c37 100644 --- a/vendor/raylib/raylib.flan +++ b/vendor/raylib/raylib.flan @@ -20,6 +20,12 @@ (defstruct Vector2 [x f32 y f32]) (defstruct Color [r u8 g u8 b u8 a u8]) +;; Texture2D is five 4-byte fields in a row, which is the layout most likely +;; to be silently wrong: permute two of them and every field still reads as a +;; plausible number. Rectangle is four floats in x/y/width/height order. +(defstruct Texture2D [id u32 width i32 height i32 mipmaps i32 format i32]) +(defstruct Rectangle [x f32 y f32 width f32 height f32]) + ;; KeyboardKey, the subset sand.flan uses. A keyword at a call site resolves ;; against these members at compile time and a typo is an error there. (defenum Key @@ -103,3 +109,57 @@ (defn draw-rectangle [x i32 y i32 width i32 height i32 color Color] (let [c color] (draw-rectangle-raw x y width height (addr c)))) + +;; ── Shapes texture ────────────────────────────────────────────────── +;; +;; raylib draws every shape from one atlas texture, and this pair sets and +;; reads it. It is bound here for a second reason: it is the only part of the +;; API that stores a Texture2D and a Rectangle and hands them back without +;; touching the GPU, so it is how the acceptance table checks both layouts +;; headlessly. Everything else that takes a texture needs a GL context. +;; +;; raylib substitutes a default ({1,1,1,1,7} / {0,0,1,1}) when the id or the +;; source's width or height is not positive, so a caller — and the test — +;; should keep clear of those values if it wants its own back. + +(declare set-shapes-texture-raw + [texture (Ptr Texture2D) source (Ptr Rectangle)] + "flan_rl_set_shapes_texture") + +(defn set-shapes-texture [texture Texture2D source Rectangle] + (let [t texture + r source] + (set-shapes-texture-raw (addr t) (addr r)))) + +(declare get-shapes-texture-raw [out (Ptr Texture2D)] "flan_rl_get_shapes_texture") + +(defn get-shapes-texture [] Texture2D + (let [t (Texture2D {})] + (get-shapes-texture-raw (addr t)) + t)) + +(declare get-shapes-texture-rectangle-raw + [out (Ptr Rectangle)] "flan_rl_get_shapes_texture_rectangle") + +(defn get-shapes-texture-rectangle [] Rectangle + (let [r (Rectangle {})] + (get-shapes-texture-rectangle-raw (addr r)) + r)) + +;; ── Shapes ────────────────────────────────────────────────────────── +;; +;; Rectangle intersection, which raylib computes from all four fields in +;; different ways. It is the one Rectangle call that needs no GPU, so it is +;; also how the acceptance table pins the layout: a store-and-return check is +;; symmetric and a permuted layout survives it untouched. + +(declare get-collision-rec-raw + [a (Ptr Rectangle) b (Ptr Rectangle) out (Ptr Rectangle)] + "flan_rl_get_collision_rec") + +(defn get-collision-rec [a Rectangle b Rectangle] Rectangle + (let [x a + y b + out (Rectangle {})] + (get-collision-rec-raw (addr x) (addr y) (addr out)) + out)) diff --git a/vendor/raylib/shim.c b/vendor/raylib/shim.c index 11db1a6..9b6ce88 100644 --- a/vendor/raylib/shim.c +++ b/vendor/raylib/shim.c @@ -19,6 +19,8 @@ typedef struct { float x, y; } Vector2; typedef struct { unsigned char r, g, b, a; } Color; +typedef struct { unsigned int id; int width, height, mipmaps, format; } Texture2D; +typedef struct { float x, y, width, height; } Rectangle; extern void InitWindow(int width, int height, const char *title); extern void CloseWindow(void); @@ -38,6 +40,10 @@ extern void EndDrawing(void); extern void DrawFPS(int x, int y); extern void ClearBackground(Color color); extern void DrawRectangle(int x, int y, int width, int height, Color color); +extern void SetShapesTexture(Texture2D texture, Rectangle source); +extern Texture2D GetShapesTexture(void); +extern Rectangle GetShapesTextureRectangle(void); +extern Rectangle GetCollisionRec(Rectangle a, Rectangle b); /* A Flan string arrives as ptr+len and is not NUL-terminated, so a C API that * wants a C string needs a copy. The window title is the only one, it is short @@ -81,3 +87,18 @@ void flan_rl_draw_rectangle(int x, int y, int width, int height, const Color *color) { DrawRectangle(x, y, width, height, *color); } + +void flan_rl_set_shapes_texture(const Texture2D *texture, const Rectangle *source) { + SetShapesTexture(*texture, *source); +} + +void flan_rl_get_shapes_texture(Texture2D *out) { *out = GetShapesTexture(); } + +void flan_rl_get_shapes_texture_rectangle(Rectangle *out) { + *out = GetShapesTextureRectangle(); +} + +void flan_rl_get_collision_rec(const Rectangle *a, const Rectangle *b, + Rectangle *out) { + *out = GetCollisionRec(*a, *b); +} From a17813514390c20060387a0ad28a5854dbd35d4e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 17:52:51 +0700 Subject: [PATCH 2/4] Textures, which cannot be tested without a GPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadTexture, UnloadTexture, the four DrawTexture variants and IsTextureValid. Nothing about them pushes against the aggregate rule: every raylib signature here takes its structs by value, and every one has an obvious pointer form the shim dereferences, so the declarations are scalars and pointers as before. The predicate is IsTextureValid and not IsTextureReady, which this version of raylib does not export at all — 5.5 renamed it, and calling the old name would be a link error rather than a silent miss. It is bound because the failure it reports is otherwise invisible: LoadTexture on a missing file returns a texture with an id of 0 and says so only on the trace log, and then every draw with it is a no-op that looks like a drawing bug. cstr's one caller used to be the window title, and its comment said so. A path is the second caller and wants far more than 256 bytes, so each caller now passes a buffer sized for what it holds. Truncating still beats reading past the end: a truncated path simply fails to open, and texture-valid? is how the program notices. None of this is in the acceptance table, and deliberately. Loading a texture needs a GL context, so anything headless would be asserting on the failure path while appearing to test the working one. It is exercised by sand.flan. --- vendor/raylib/raylib.flan | 73 +++++++++++++++++++++++++++++++++++++++ vendor/raylib/shim.c | 48 +++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/vendor/raylib/raylib.flan b/vendor/raylib/raylib.flan index 9450c37..e50822c 100644 --- a/vendor/raylib/raylib.flan +++ b/vendor/raylib/raylib.flan @@ -163,3 +163,76 @@ out (Rectangle {})] (get-collision-rec-raw (addr x) (addr y) (addr out)) out)) + +;; ── Textures ──────────────────────────────────────────────────────── +;; +;; Everything here needs a GL context, so a window has to be open first — +;; load-texture before init-window returns an id of 0 and raylib says so on +;; the log. texture-valid? is how that is noticed in the program rather than +;; only in the log; raylib 5.5 spells it IsTextureValid, and IsTextureReady, +;; which older code calls, does not exist in this version. + +(declare load-texture-raw [path string out (Ptr Texture2D)] "flan_rl_load_texture") + +(defn load-texture [path string] Texture2D + (let [t (Texture2D {})] + (load-texture-raw path (addr t)) + t)) + +(declare texture-valid?-raw [texture (Ptr Texture2D)] bool "flan_rl_is_texture_valid") + +(defn texture-valid? [texture Texture2D] bool + (let [t texture] + (texture-valid?-raw (addr t)))) + +(declare unload-texture-raw [texture (Ptr Texture2D)] "flan_rl_unload_texture") + +(defn unload-texture [texture Texture2D] + (let [t texture] + (unload-texture-raw (addr t)))) + +(declare draw-texture-raw + [texture (Ptr Texture2D) x i32 y i32 tint (Ptr Color)] + "flan_rl_draw_texture") + +(defn draw-texture [texture Texture2D x i32 y i32 tint Color] + (let [t texture + c tint] + (draw-texture-raw (addr t) x y (addr c)))) + +(declare draw-texture-v-raw + [texture (Ptr Texture2D) position (Ptr Vector2) tint (Ptr Color)] + "flan_rl_draw_texture_v") + +(defn draw-texture-v [texture Texture2D position Vector2 tint Color] + (let [t texture + p position + c tint] + (draw-texture-v-raw (addr t) (addr p) (addr c)))) + +(declare draw-texture-ex-raw + [texture (Ptr Texture2D) position (Ptr Vector2) rotation f32 scale f32 + tint (Ptr Color)] + "flan_rl_draw_texture_ex") + +(defn draw-texture-ex [texture Texture2D position Vector2 rotation f32 + scale f32 tint Color] + (let [t texture + p position + c tint] + (draw-texture-ex-raw (addr t) (addr p) rotation scale (addr c)))) + +;; A negative source width or height flips the sprite, which is how a sheet is +;; drawn facing the other way without a second image. +(declare draw-texture-rec-raw + [texture (Ptr Texture2D) source (Ptr Rectangle) position (Ptr Vector2) + tint (Ptr Color)] + "flan_rl_draw_texture_rec") + +(defn draw-texture-rec [texture Texture2D source Rectangle position Vector2 + tint Color] + (let [t texture + s source + p position + c tint] + (draw-texture-rec-raw (addr t) (addr s) (addr p) (addr c)))) diff --git a/vendor/raylib/shim.c b/vendor/raylib/shim.c index 9b6ce88..a993548 100644 --- a/vendor/raylib/shim.c +++ b/vendor/raylib/shim.c @@ -15,6 +15,7 @@ #include #include +#include #include typedef struct { float x, y; } Vector2; @@ -44,10 +45,22 @@ extern void SetShapesTexture(Texture2D texture, Rectangle source); extern Texture2D GetShapesTexture(void); extern Rectangle GetShapesTextureRectangle(void); extern Rectangle GetCollisionRec(Rectangle a, Rectangle b); +extern Texture2D LoadTexture(const char *fileName); +extern bool IsTextureValid(Texture2D texture); +extern void UnloadTexture(Texture2D texture); +extern void DrawTexture(Texture2D texture, int posX, int posY, Color tint); +extern void DrawTextureV(Texture2D texture, Vector2 position, Color tint); +extern void DrawTextureEx(Texture2D texture, Vector2 position, float rotation, + float scale, Color tint); +extern void DrawTextureRec(Texture2D texture, Rectangle source, + Vector2 position, Color tint); /* A Flan string arrives as ptr+len and is not NUL-terminated, so a C API that - * wants a C string needs a copy. The window title is the only one, it is short - * by nature, and truncating is better than reading past the end. */ + * wants a C string needs a copy. Two callers want one: the window title and a + * texture's file path. Each passes a buffer big enough for what it is — 256 + * for a title, PATH_MAX for a path — and truncating is better than reading + * past the end. A truncated path fails to open and LoadTexture returns an id + * of 0, which is what texture-valid? is for. */ static const char *cstr(const char *p, long long n, char *buf, size_t cap) { size_t k = (size_t)n < cap - 1 ? (size_t)n : cap - 1; memcpy(buf, p, k); @@ -102,3 +115,34 @@ void flan_rl_get_collision_rec(const Rectangle *a, const Rectangle *b, Rectangle *out) { *out = GetCollisionRec(*a, *b); } + +void flan_rl_load_texture(const char *path, long long n, Texture2D *out) { + char buf[PATH_MAX]; + *out = LoadTexture(cstr(path, n, buf, sizeof buf)); +} + +bool flan_rl_is_texture_valid(const Texture2D *texture) { + return IsTextureValid(*texture); +} + +void flan_rl_unload_texture(const Texture2D *texture) { UnloadTexture(*texture); } + +void flan_rl_draw_texture(const Texture2D *texture, int x, int y, + const Color *tint) { + DrawTexture(*texture, x, y, *tint); +} + +void flan_rl_draw_texture_v(const Texture2D *texture, const Vector2 *position, + const Color *tint) { + DrawTextureV(*texture, *position, *tint); +} + +void flan_rl_draw_texture_ex(const Texture2D *texture, const Vector2 *position, + float rotation, float scale, const Color *tint) { + DrawTextureEx(*texture, *position, rotation, scale, *tint); +} + +void flan_rl_draw_texture_rec(const Texture2D *texture, const Rectangle *source, + const Vector2 *position, const Color *tint) { + DrawTextureRec(*texture, *source, *position, *tint); +} From 01603843c0faf9b55c94351365d75e00d46c6c36 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 17:56:03 +0700 Subject: [PATCH 3/4] A sprite in sand, because running it is the only test there is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sand.flan now loads a 16x8 sheet of two 8x8 brush frames and draws it four ways: the frame under the cursor through draw-texture-rec, and three badges in the corner through draw-texture, draw-texture-v and draw-texture-ex. That is not decoration — it is one call site per binding that the acceptance table cannot reach, and without it draw-texture-v and draw-texture-ex would be code nobody had ever executed. A failed load says so by name. LoadTexture on a missing file returns an id of 0, and every draw with that texture silently does nothing, so the program would look like it had a drawing bug rather than a missing file. texture-valid? is asked once at load and the answer is both printed and remembered, so the sand still runs with the cursor off. brush.png is generated rather than drawn — two circles, one ring and one filled, 102 bytes — so the repository gains an asset nobody has to keep. What this was checked by: xvfb-run, a screenshot of the running window, and the badges counted in it. Also with brush.png moved away, which is how the refusal path above is known to fire rather than merely to compile. --- NEXT.md | 24 ++++++++++++++++++++---- brush.png | Bin 0 -> 102 bytes sand.flan | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 brush.png diff --git a/NEXT.md b/NEXT.md index 7d6f7e2..74ac027 100644 --- a/NEXT.md +++ b/NEXT.md @@ -237,14 +237,30 @@ little-endian reading of the packed integer, so an identity would have passed a weaker test. That case is in the acceptance table, skipped if `libraylib` is not installed. -The bindings are 18 calls: window (`init-window`, `close-window`, +The bindings are 29 calls: window (`init-window`, `close-window`, `window-should-close?`, `set-target-fps`, `set-trace-log-level`), keyboard (`key-pressed?`/`down?`/`released?`), mouse (`mouse-button-pressed?`/`down?`/ -`released?`, `get-mouse-position`), `get-color`, and drawing (`begin-drawing`, -`end-drawing`, `draw-fps`, `clear-background`, `draw-rectangle`), plus the -`Key`, `MouseButton` and `TraceLogLevel` enums. Adding one is three lines: a +`released?`, `get-mouse-position`), `get-color`, drawing (`begin-drawing`, +`end-drawing`, `draw-fps`, `clear-background`, `draw-rectangle`), textures +(`load-texture`, `texture-valid?`, `unload-texture`, `draw-texture`, +`draw-texture-v`, `draw-texture-ex`, `draw-texture-rec`), and the shapes +texture and rectangle intersection (`set-shapes-texture`, +`get-shapes-texture`, `get-shapes-texture-rectangle`, `get-collision-rec`), +plus the `Key`, `MouseButton` and `TraceLogLevel` enums and the `Vector2`, +`Color`, `Texture2D` and `Rectangle` structs. Adding one is three lines: a `declare`, an `extern` prototype, and a one-line wrapper. +The texture calls are the first ones with no headless test, because loading +one needs a GL context. What the acceptance case does instead is pin the two +new struct layouts using the only things raylib computes from those fields +without a GPU: `GetCollisionRec`, which pins `Rectangle` completely, and +`SetShapesTexture`'s default substitution, which pins `Texture2D`'s `id` and +`format` and nothing else. Handing a struct over and reading it back proves +nothing at all — storing and returning is symmetric, so a permuted layout +comes back permuted the same way and the case passes. `width`, `height` and +`mipmaps` are therefore checked only by looking at `sand.flan` running, which +draws the brush sprite four ways for that reason. + No raylib headers are needed: `shim.c` declares the prototypes it uses, so the build depends on the shared library being linkable and not on `raylib-devel`. `vendor/raylib/link` carries `-l:libraylib.so.550` because Fedora ships the diff --git a/brush.png b/brush.png new file mode 100644 index 0000000000000000000000000000000000000000..f2e96d4e59f151e20712a46d61c2bbe02d0f80d2 GIT binary patch literal 102 zcmeAS@N?(olHy`uVBq!ia0vp^0zk~c!3HEhl+{lMQo5cljv*Ddk`oq){rIo&(BA0S zg!qIH@{-9{dnWfUlRPZ2#_ZBvhORvhg$hAT3|)U`&E)K`t^jIe@O1TaS?83{1OT9; BAfx~Q literal 0 HcmV?d00001 diff --git a/sand.flan b/sand.flan index 8671f69..eb29636 100644 --- a/sand.flan +++ b/sand.flan @@ -28,6 +28,40 @@ (import sim "sand-sim") ; no collection prefix: relative to this file (import agent "vendor:agent") ; the dev agent: redefinitions, installed below +;; The brush sprite: a 16x8 sheet of two 8x8 frames, the ring drawn while the +;; mouse is idle and the blob while it is painting. It is here because the +;; texture calls cannot be in the acceptance table at all — loading one needs a +;; GL context — so the only way they are exercised is by running this. +(defvar brush rl/Texture2D) +(defvar brush-ok bool) + +;; A missing file is not a crash and not silence: LoadTexture hands back a +;; texture with an id of 0, every draw with it is a no-op, and the program +;; looks like it has a drawing bug. So it is asked and said once, here. +(defn load-brush [] + (set brush (rl/load-texture "brush.png")) + (set brush-ok (rl/texture-valid? brush)) + (unless brush-ok + (print-line "sand: cannot load brush.png — drawing the cursor is off"))) + +;; Four draws, one per shape the call comes in, because none of them can be in +;; the acceptance table. The cursor picks one frame out of the sheet and so +;; needs a source rectangle; the three badges in the corner are the whole +;; sheet, at integer coordinates, at a Vector2 tinted with the current sand +;; colour, and scaled up — draw-texture, draw-texture-v and draw-texture-ex. +(defn draw-brush [] + (when brush-ok + (let [m (rl/get-mouse-position) + frame (f32 (if (rl/mouse-button-down? :left) 8.0 0.0))] + (rl/draw-texture-rec brush + (rl/Rectangle {:x frame :y 0.0 :width 8.0 :height 8.0}) + (rl/Vector2 {:x (- (.x m) 4.0) :y (- (.y m) 4.0)}) + rl/white) + (rl/draw-texture brush 20 50 rl/white) + (rl/draw-texture-v brush (rl/Vector2 {:x 44.0 :y 50.0}) + (rl/get-color (nth sim/colors sim/current-color))) + (rl/draw-texture-ex brush (rl/Vector2 {:x 72.0 :y 46.0}) 0.0 2.0 rl/white)))) + ;; Locals are assignable places (spec-memory.md); parameters are not. (defn paint [] (let [m (rl/get-mouse-position) @@ -61,6 +95,7 @@ (i32 (* row sim/cell-size)) sim/cell-size sim/cell-size (rl/get-color c)))))) + (draw-brush) (rl/draw-fps 20 20)) (defn main [] @@ -68,6 +103,10 @@ (rl/init-window sim/screen-width sim/screen-height "SAND") (defer (rl/close-window)) (rl/set-target-fps 120) + ;; After the window, never before: LoadTexture uploads to the GPU and there + ;; is no GPU to upload to until InitWindow has made a context. + (load-brush) + (defer (rl/unload-texture brush)) ;; 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 16f16055806c10beb2049ea7ed0cbac542724c61 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 17:59:00 +0700 Subject: [PATCH 4/4] The FFI case at -O0 too, where the allocas are still there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other program in the table is run twice for the reason stated next to them: at -O2 mem2reg launders a sloppy alloca, so -O0 is what tests the IR actually emitted. The raylib case had been the exception, and it is the worst one to exempt — five of its calls hand C the address of a local struct, which is exactly the alloca that comment is about. It passes as it stands. That is the point: the case that only ever ran optimised was a coincidence away from hiding something. --- test/test_acceptance.ml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 7e2466e..14bb5ad 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -159,13 +159,21 @@ let () = symmetric. What the last case cannot pin, because nothing raylib computes without a GL context reads them, is width, height and mipmaps against each other. *) - if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then - outputs "raylib ffi, headless" "programs/raylib-ffi.flan" - "17\n34\n51\n68\n\ - 6\n1\n4\n3\n\ - 7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\n\ - 1\n1\n1\n1\n7\n\ - 7\n0\n17\n2\n4\n" + let raylib_out = + "17\n34\n51\n68\n\ + 6\n1\n4\n3\n\ + 7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\n\ + 1\n1\n1\n1\n7\n\ + 7\n0\n17\n2\n4\n" + in + if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin + outputs "raylib ffi, headless" "programs/raylib-ffi.flan" raylib_out; + (* And at -O0, for the reason the rest of the table is: every struct + here crosses as (addr v) on a local, which is the alloca mem2reg + would launder before anyone noticed it was wrong. *) + outputs ~opt:"-O0" "raylib ffi, headless, -O0" "programs/raylib-ffi.flan" + raylib_out + end else print_endline "acceptance: skipping the raylib FFI case (no libraylib)";