From a5fa83fe878b8ad6cf348a03389bcba37a1c0ea4 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 07:50:03 +0700 Subject: [PATCH] The renderer was one binding away, and the import was not allowed to be the answer --- BUILT.md | 36 +++++++++++++++ NEXT.md | 13 +++--- PORTING.md | 78 ++++++++++++++++++++++++++------- test/programs/raylib-ffi.flan | 29 ++++++++++++ test/programs/raylib-image.flan | 40 +++++++++++++++++ test/test_acceptance.ml | 14 +++++- vendor/raylib/raylib.flan | 38 +++++++++++++++- 7 files changed, 223 insertions(+), 25 deletions(-) diff --git a/BUILT.md b/BUILT.md index b6b7d8d..36772af 100644 --- a/BUILT.md +++ b/BUILT.md @@ -3897,3 +3897,39 @@ composite renderer and ghost text want the same form, for different reasons. `fl things ghost text would need — overlay invalidation as the buffer is edited, and a rule for a watch inside a loop, which the buffer sidesteps by showing the last value written and which inline has no obvious answer that does not become the query UI this design exists to avoid. + +## The four raylib lines siam-farmer needed + +`PORTING.md` measured the author's game against the binding and found the renderer unwritable in a default build. +Four lines closed it, and the interesting part is not the lines. + +`draw-texture-pro` is the one that mattered. It is the only call in the package that takes both a source rectangle and +a destination rectangle, which is what a tilemap is: `source` picks a cell out of an atlas, `dest` says where it lands +and how big, and a 16px tile drawn at 4x is a dest four times the source. `draw-texture-rec` has the source and no +scale; `draw-texture-ex` has the scale and no source. Neither half draws a tile. + +It *was* reachable — with `FLAN_RAYLIB_H` exported the importer brings it in with 250-odd others — and that is the +finding worth keeping. `vendor/raylib/headers` keeps the import opt-in on purpose, so a build needs libraylib linkable +and not raylib-devel installed. That property is worth keeping and it means **the default build had no draw call for a +grid-based game**. The rule that follows: *a raylib function on a game's per-frame path is hand-written in +`raylib.flan` and checked against the header; it is not left to the import.* The import widens the surface; it must +not be load-bearing. + +The other three: `image-from-image` (the non-mutating `image-crop` — carving a sheet into twenty tiles with +`image-crop` destroys the sheet on the first one, and 5.5 has no `ImageCopy`), `window-ready?` (an engine's "am I +already running" guard), and `left-shift 340` in the `Key` `defenum`. Nothing else went into the enum: it carries the +keys that have a customer, and `PORTING.md` §5 checked every other value the game touches and found them all present. + +**What could be tested, and what could not.** `PORTING.md` asked for an acceptance case making raylib compute with the +source rect so a permuted `Rectangle` goes red. That cannot exist for `DrawTexturePro` — it needs a GL context, and +`raylib.flan`'s Shapes comment already says none of the drawing calls can be in the table. So `raylib-ffi.flan` links +it instead: the call sits behind `(when (rl/window-ready?) …)`, false headless, so the shim is generated and the +symbol resolves at link time and the body never runs. That catches a name or an arity libraylib does not have. It does +**not** catch the argument order, and three structs in a row is where an argument order goes wrong. Only looking at +the screen catches that, and saying so is better than a test that implies otherwise. + +The computed case moved to `image-from-image`, which is CPU-side and is exactly what the Images section says is +assertable. `raylib-image.flan` carves one 6×3 sheet twice at two different `y`s and then re-reads the sheet: `x`, +`y`, `width` and `height` are each pinned by an answer that moves if they do, and the sheet surviving both carves is +what distinguishes this from `image-crop`. Bind it to `ImageCrop` by mistake and the second carve reads out of a 2×1 +image and the case goes red. diff --git a/NEXT.md b/NEXT.md index 70ec092..bb777bc 100644 --- a/NEXT.md +++ b/NEXT.md @@ -2,13 +2,14 @@ Three things, in order. The first two are one line each and unblock a real game. -**1. `DrawTexturePro` is not bound, and it is the one true blocker for `siam-farmer`.** See `PORTING.md`, written -against both the Clojure implementation and the WIP Common Lisp port. Every tile in both goes through it; -`DrawTextureRec` does not scale and `DrawTextureEx` takes no source rect, so the renderer **cannot be written at all** -in a default build. It is reachable only through the opt-in `FLAN_RAYLIB_H` import, which `vendor/raylib/headers` -deliberately keeps optional. One `declare-c` line. +**1. `DrawTexturePro` is not bound — DONE.** It is `draw-texture-pro` in `vendor/raylib/raylib.flan` now, hand-written +beside `draw-texture-rec` and read off a raylib header rather than remembered. `image-from-image` and `window-ready?` +went in with it. The rule the gap exposed is written down in `BUILT.md` and `PORTING.md` §1: *a raylib function on a +game's per-frame path is hand-written and header-checked, not left to the opt-in import* — the import widens the +surface and must not be load-bearing, because the default build has no `FLAN_RAYLIB_H` and still has to draw. -**2. `Key` has no `left-shift`.** Both implementations use shift+1..5 to pick the tilemap. One enum member. +**2. `Key` has no `left-shift` — DONE.** `left-shift 340`, and nothing else: `PORTING.md` §5 checked every other enum +value the game touches and they were all already right. **3. An out-of-bounds index should signal a condition, not `exit(134)`.** `PORTING.md`'s own first recommendation after the binding lines. The game indexes grids straight from mouse coordinates — `game.lisp` had to add an diff --git a/PORTING.md b/PORTING.md index d88c68e..3bb23c0 100644 --- a/PORTING.md +++ b/PORTING.md @@ -27,6 +27,11 @@ hypothetical. ## 1. The one thing that cannot be written at all +> **Bound, 2026-09-13.** `draw-texture-pro`, `image-from-image`, `window-ready?` and +> `Key/left-shift` are all hand-written in `vendor/raylib/raylib.flan` now. The section +> is kept as it was written, because the reasoning is the part worth having; what the +> fix cost, and what could and could not be tested, is in the box at the end of it. + ### `DrawTexturePro` is not bound Every tile in this game is drawn by one call: @@ -77,6 +82,40 @@ And one enum member, not a function: **`raylib.flan`'s `Key` has no `left-shift` `KEY_LEFT_SHIFT` (340). The `defenum` carries a deliberate subset — the keys `sand.flan` uses — and this is one member short of what this game needs. One entry. +### What the four lines actually cost, and what a test could say about them + +All four landed together: `draw-texture-pro` beside `draw-texture-rec`, +`image-from-image` beside `image-crop`, `window-ready?` beside `window-should-close?`, +and `left-shift 340` at the end of the `Key` `defenum`. The three signatures were read +off a raylib header rather than remembered, and all three are unchanged across 5.1, 5.5 +and the 6.0 this game vendors. + +**The test suggestion further down this file was wrong and is corrected here.** Tier 0 +asked for "an acceptance case that makes raylib compute with the source rect so a +permuted `Rectangle` goes red". `DrawTexturePro` cannot have one: it needs a GL context, +and `raylib.flan`'s own Shapes comment already says none of the drawing calls can be in +the acceptance table. What `raylib-ffi.flan` does instead is link it — the call sits +behind `(when (rl/window-ready?) …)`, which is false headless, so the shim is generated +and the symbol is resolved at link time and the body never runs. That catches a name or +an arity that does not exist in libraylib. It does **not** catch the argument order, and +three structs in a row is exactly where an argument order goes wrong. Only looking at +the screen catches that. + +The computed test the suggestion wanted does exist — on `image-from-image`, which is +CPU-side and is what that Images section comment says is assertable. +`raylib-image.flan` carves the same 6×3 sheet twice, at two different `y`s, and then +re-reads the sheet: the rectangle's `x`, `y`, `width` and `height` are each pinned by an +answer that changes if they move, and the source surviving both carves is what +distinguishes this from `image-crop`. Bind it to `ImageCrop` by mistake and the second +carve reads out of a 2×1 image and the case goes red. + +**And the rule Tier 0 item 4 asked for, written down:** *a raylib function on a game's +per-frame path is hand-written in `raylib.flan` and checked against the header; it is +not left to the opt-in import.* The import widens the surface and is worth having, but a +build that does not have `FLAN_RAYLIB_H` set is the default build, and the default build +has to be able to draw. The test that goes with the rule is a link check, which is cheap +and is all a GL-context call can have. + --- ## 2. What looks like a gap and is not @@ -405,15 +444,19 @@ path; the remainder is unchecked and should not be read as verified. through `KEY_NINE`=57, `KEY_SPACE`=32, `KEY_E`=69, `KEY_LEFT_SHIFT`=340, `MOUSE_BUTTON_LEFT`/`RIGHT`/`MIDDLE`=0/1/2, and `LOG_WARNING` as the fifth `TraceLogLevel` member (4) are all unchanged in 6.0 and all match `raylib.flan` — except -that `left-shift` is absent from the `Key` `defenum` entirely, which is the §1 note, not a -skew. +that `left-shift` was absent from the `Key` `defenum` entirely, which is the §1 note and +not a skew. **It is `left-shift 340` there now**, and nothing else went in beside it: the +`defenum`'s own comment says it carries the keys that have a customer, and of the +modifier siblings only this one does. This paragraph is the record that every other enum +value the game touches was already present and already right, so "check the surrounding +enum for other omissions" has an answer and the answer is none. So the version skew is not a correctness problem for this game today. It is still worth closing, because the whole point of `headers` is that a silent disagreement is the failure mode, and "I checked by hand once" is exactly the state `headers` was built to replace. -The binding's real gap against `rl.clj` is §1: `DrawTexturePro`, then `ImageFromImage` and -`IsWindowReady`. +The binding's real gap against `rl.clj` was §1: `DrawTexturePro`, then `ImageFromImage` +and `IsWindowReady`. All three are bound. --- @@ -422,22 +465,25 @@ The binding's real gap against `rl.clj` is §1: `DrawTexturePro`, then `ImageFro Split into two tiers, because a one-line binding fix and a month of language work should not compete for the same slot. -### Tier 0 — bindings. Hours, not weeks. Do these first. +### Tier 0 — bindings. Hours, not weeks. **Done, 2026-09-13.** -1. **`declare-c draw-texture-pro`.** Unblocks the entire renderer. Without it this game - has no draw call. One line, plus an acceptance case that makes raylib compute with the - source rect so a permuted `Rectangle` goes red. +1. ~~**`declare-c draw-texture-pro`.**~~ Bound. The renderer is writable. The acceptance + case this asked for could not be written as described — see §1's closing box — so it + is a link check behind a `window-ready?` guard, and the computed test moved to + `image-from-image` where raylib does the arithmetic on the CPU. -2. **`left-shift` in the `Key` `defenum`.** Shift+1..5 picks the tilemap in both - implementations, and the member is not there. One entry, and without it that input is - unwritable. +2. ~~**`left-shift` in the `Key` `defenum`.**~~ One entry, `340`. §5 records that nothing + else in the enum was missing. -3. **`declare-c image-from-image` and `window-ready?`.** The atlas tool and the engine's - reentrancy guard. Two lines. +3. ~~**`declare-c image-from-image` and `window-ready?`.**~~ Both bound. + `image-from-image` carries the strongest new assertion in the table: two carves at + different `y`s out of one sheet, and the sheet re-read afterwards to show it was not + destroyed. -4. **Decide the rule the first item exposes.** A function the game calls every frame - should be hand-written and header-checked, not left to the opt-in import. Worth writing - down, because the next game-shaped program will find the next `DrawTexturePro`. +4. ~~**Decide the rule the first item exposes.**~~ Decided and written into §1: *a raylib + function on a game's per-frame path is hand-written in `raylib.flan` and checked + against the header, not left to the opt-in import.* The default build has no + `FLAN_RAYLIB_H` and the default build has to be able to draw. ### Tier 1 — language and tooling, in the order that unblocks the most of this game diff --git a/test/programs/raylib-ffi.flan b/test/programs/raylib-ffi.flan index a5a8400..a8de1c9 100644 --- a/test/programs/raylib-ffi.flan +++ b/test/programs/raylib-ffi.flan @@ -288,4 +288,33 @@ (print (rl/get-gamepad-axis-movement 0 (rl/GamepadAxis 9))) (println "") + ;; ── window-ready?, and the one draw call that has to link ─────────── + ;; + ;; No window was opened, so this is false — which is the answer an engine's + ;; "am I already running" guard needs, and the only thing about it that can + ;; be asserted without a display. + (show-bool "window ready" (rl/window-ready?)) + + ;; draw-texture-pro needs a GL context, so it cannot be *called* here and + ;; cannot be in the assertion table at all — the whole Shapes and Textures + ;; section is in that position. What it can be is *linked*, which is what + ;; this branch is for: the shim is generated, the symbol is resolved at link + ;; time, and a signature that does not exist in libraylib fails the build + ;; rather than the frame a game first draws in. The guard is + ;; window-ready?, which has just printed no, so the body never runs. + ;; + ;; This is the honest limit of what a headless table says about a draw call, + ;; and it is worth saying out loud: nothing here checks the argument order. + ;; The source rect, the dest rect and the origin are three structs in a row + ;; and permuting them links perfectly. Only looking at the screen catches + ;; that, which is why the file's header says so about the Shapes family. + (when (rl/window-ready?) + (rl/draw-texture-pro (rl/Texture2D {.id 0 .width 0 .height 0 + .mipmaps 0 .format 0}) + (rl/Rectangle {.x 0.0 .y 0.0 .width 16.0 .height 16.0}) + (rl/Rectangle {.x 0.0 .y 0.0 .width 64.0 .height 64.0}) + (rl/Vector2 {.x 0.0 .y 0.0}) + 0.0 + rl/white)) + 0) diff --git a/test/programs/raylib-image.flan b/test/programs/raylib-image.flan index 8ac680a..82811a5 100644 --- a/test/programs/raylib-image.flan +++ b/test/programs/raylib-image.flan @@ -157,4 +157,44 @@ (show-pixel "cropped at 0,0" img 0 0) (rl/unload-image img)) + ;; ── image-from-image, which is crop without the destruction ───────── + ;; + ;; The same 6 x 3 image and the same two marks, carved twice. The point that + ;; matters is the one the assertions below make in three parts: + ;; + ;; 1. It reads the rectangle the same way image-crop does — (4,0,2,1) + ;; picks two pixels of the first row and the mark at (5,0) lands at + ;; (1,0) of a 2 x 1 result. Exchange width and height and the result is + ;; 1 x 2 with nothing in it. + ;; + ;; 2. It reads `y`. The second carve is (4,2,2,1), one row lower than + ;; anything image-crop's case reaches, and the mark it finds is the + ;; *other* colour. A binding that ignored y would answer mark-a twice. + ;; + ;; 3. **The source survives.** That is the whole reason this exists beside + ;; image-crop: crop mutates in place, so carving a sheet into twenty + ;; tiles with it destroys the sheet on the first one. The original is + ;; re-read after both carves and still reports 6 x 3 with both marks + ;; where they were put. Bind this to ImageCrop by mistake and the second + ;; carve reads out of a 2 x 1 image and the source check goes red. + (let [sheet (rl/gen-image-color 6 3 bg)] + (rl/image-draw-pixel (addr sheet) 5 0 mark-a) + (rl/image-draw-pixel (addr sheet) 4 2 mark-b) + (let [top (rl/image-from-image + sheet (rl/Rectangle {.x 4.0 .y 0.0 .width 2.0 .height 1.0}))] + (show-image "piece-top" top) + (show-pixel "piece-top at 1,0" top 1 0) + (show-pixel "piece-top at 0,0" top 0 0) + (rl/unload-image top)) + (let [bottom (rl/image-from-image + sheet (rl/Rectangle {.x 4.0 .y 2.0 .width 2.0 .height 1.0}))] + (show-image "piece-bottom" bottom) + (show-pixel "piece-bottom at 0,0" bottom 0 0) + (show-pixel "piece-bottom at 1,0" bottom 1 0) + (rl/unload-image bottom)) + (show-image "sheet after" sheet) + (show-pixel "sheet at 5,0" sheet 5 0) + (show-pixel "sheet at 4,2" sheet 4 2) + (rl/unload-image sheet)) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 54fd10b..0cb7ef2 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -774,7 +774,8 @@ let () = point in poly yes\npoint outside poly no\n\ in square, four corners yes\nout of triangle, three no\n\ no crossing\n\ - axes 0 0 0 0 -1 -1 past-end 0\n" + axes 0 0 0 0 -1 -1 past-end 0\n\ + window ready no\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; @@ -876,7 +877,16 @@ let () = resized 2 6 1 7\n\ cropped 2 1 1 7\n\ cropped at 1,0 200 0 0 255\n\ - cropped at 0,0 10 20 30 255\n" + cropped at 0,0 10 20 30 255\n\ + piece-top 2 1 1 7\n\ + piece-top at 1,0 200 0 0 255\n\ + piece-top at 0,0 10 20 30 255\n\ + piece-bottom 2 1 1 7\n\ + piece-bottom at 0,0 0 200 0 255\n\ + piece-bottom at 1,0 10 20 30 255\n\ + sheet after 6 3 1 7\n\ + sheet at 5,0 200 0 0 255\n\ + sheet at 4,2 0 200 0 255\n" in if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin outputs "raylib images, headless" "programs/raylib-image.flan" diff --git a/vendor/raylib/raylib.flan b/vendor/raylib/raylib.flan index f8ed5ad..c7df385 100644 --- a/vendor/raylib/raylib.flan +++ b/vendor/raylib/raylib.flan @@ -52,7 +52,8 @@ j 74 k 75 l 76 m 77 n 78 o 79 p 80 q 81 r 82 s 83 t 84 u 85 v 86 w 87 x 88 y 89 z 90 escape 256 enter 257 tab 258 backspace 259 - right 262 left 263 down 264 up 265]) + right 262 left 263 down 264 up 265 + left-shift 340]) (defenum MouseButton [left 0 right 1 middle 2 side 3 extra 4 forward 5 back 6]) @@ -65,6 +66,12 @@ (declare-c init-window [width i32 height i32 title string] "InitWindow") (declare-c close-window [] "CloseWindow") (declare-c window-should-close? [] bool "WindowShouldClose") + +;; False before init-window and after close-window, true between. A game loop +;; started twice is the thing this answers — an engine that can be re-entered +;; from a REPL or a dev session asks it before opening a second window onto +;; the same context. +(declare-c window-ready? [] bool "IsWindowReady") (declare-c set-target-fps [fps i32] "SetTargetFPS") (declare-c set-trace-log-level [level TraceLogLevel] "SetTraceLogLevel") @@ -355,6 +362,23 @@ (declare-c draw-texture-rec [texture Texture2D source Rectangle position Vector2 tint Color] "DrawTextureRec") +;; The two above, in one call, and the only one of the four that both takes a +;; source rectangle and scales: `source` picks a cell out of an atlas, `dest` +;; says where on the screen it lands and how big, so a 16px tile drawn at 4x is +;; a dest four times the source. draw-texture-rec has the source and no scale; +;; draw-texture-ex has the scale and no source. Neither half is usable alone +;; for a tilemap, which is why this is the draw call a grid-based game makes +;; every frame and for every tile. +;; +;; `origin` is the point within *dest* that lands on dest's x,y and that +;; `rotation` (degrees, clockwise) turns about — {0 0} draws from the corner, +;; and half the dest size spins a tile about its middle. A negative source +;; width or height flips, the same as in draw-texture-rec. +(declare-c draw-texture-pro + [texture Texture2D source Rectangle dest Rectangle origin Vector2 + rotation f32 tint Color] + "DrawTexturePro") + ;; ── Images ────────────────────────────────────────────────────────── ;; ;; An Image is pixels in RAM. Nothing here touches the GPU, which makes it the @@ -435,6 +459,18 @@ (declare-c image-crop [image (Ptr Image) crop Rectangle] "ImageCrop") +;; The non-mutating form of the line above, and the reason it is worth having +;; both: image-crop changes the image it is given, so carving a sheet into +;; twenty tiles with it destroys the sheet on the first one. This returns a +;; fresh Image and leaves the original alone. There is no ImageCopy in 5.5, so +;; this is also how a whole image is duplicated — a rec covering all of it. +;; +;; The result owns its own buffer: unload-image it, like anything else that +;; allocated. +(declare-c image-from-image + [image Image rec Rectangle] Image + "ImageFromImage") + (declare-c image-flip-horizontal [image (Ptr Image)] "ImageFlipHorizontal") (declare-c image-flip-vertical [image (Ptr Image)] "ImageFlipVertical")