The renderer was one binding away, and the import was not allowed to be the answer

This commit is contained in:
Joseph Ferano 2026-09-13 07:50:03 +07:00
parent 54027ca942
commit a5fa83fe87
7 changed files with 223 additions and 25 deletions

View File

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

13
NEXT.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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