flan/PORTING.md
Joseph Ferano 3e181b52b2 The docs described a language that stopped existing today
A consistency sweep, run by checking claims against the compiler rather
than reading for style. Every edit here corrects something that is false
now, or adds something the page had no way to say.

`web/index.html` was the worst of it, and the worst of that was control
flow: the page said there is no `loop`/`recur` and no `break` or
`continue`, and printed the refusal message for `break` as evidence. All
four are built, with loop labels. A live code example called
`index-of-i32`, which no longer exists and would not compile. The prelude
table was the pre-generics per-type families, a paragraph said there is
no `println` two paragraphs after calling `println` the compiler's, and
`sqrt-f32` was "the one `declare` in the file" when there are five. The
"Not implemented yet" table listed `Vec`, `Map`, `Handle`, `Fn`, `fn`,
unions, `defmacro` and quasiquote, all of which check; what is actually
left is `Result`, `try`, a quoted symbol as a value, `errdefer`, `await`,
`handler-case` and the restart-stack readers. Restarts take parameters
(`(invoke-restart 'use-value 21)` answers 42), `defer` in a `let` is
allowed, and there is both an allocator and a `context`.

Generics is a new section, because nobody had documented the syntax. The
brief for it was wrong in one place and the corpus settled it: `$t` goes
in *every* type position including the return type, and bare `t` is the
type-name argument in expression position — `(vec-new t)`, `(t x)`. It
says what a type variable is move-only by default means, since that is
the rule a reader hits first and it is not Odin's.

`FLAN_RAYLIB_H` is gone from every doc that claimed it still decided
something. The passages that say "this used to be opt-in" are kept and
labelled; the ones that said "this is opt-in" are not. `plan.org` had
`{string i32}` in the type list and four predicates where there are five.
`conditions.org` described `errdefer`'s behaviour without saying it is
refused by name. `REFERENCES.md` pointed at the gitignored copy of the
raylib header rather than the committed one, which is the exact trap that
made committing it necessary.

Found and not fixed, because it is not documentation: `vendor/raylib/headers`
still says a build reads it "when the variable happens to be set", which
contradicts the section below it in the same file and is false — moving
the header makes every build fail by name.
2026-09-13 18:04:24 +07:00

38 KiB
Raw Blame History

Porting siam-farmer

What the author's game needs from Flan that Flan does not have yet, measured against the code that exists today rather than against a plan.

~/Development/siam-farmer holds two implementations of the same game. The Clojure one (src/*.clj, ~1500 lines) is the complete thought. The Common Lisp one (src/engine.lisp, src/game.lisp, src/tilemap-blob.lisp) is the author working the same design through a compiled, non-lazy, manually-managed host. Where the two differ is the most reliable evidence in this report: every place the Lisp port abandoned a Clojure construct is a place Flan does not have to supply one, and the author has already paid the cost of finding that out.

Coverage. I read, in full: src/engine.clj, src/game.clj, src/rl.clj, src/sprite_atlas.clj, src/tilemap_blob.clj, src/util.clj, src/check.clj, src/watch.clj, src/editor.clj, and the whole Lisp port — src/engine.lisp, src/game.lisp, src/tilemap-blob.lisp. On the Flan side: vendor/raylib/raylib.flan and its link/headers/build-web.sh, sand.flan, lib/prelude.ml's export surface, lib/check.ml's container and global rules, runtime/flan_rt.c's trap path, lib/dev.ml's request gating, and the relevant test/programs/*.flan. I did not read MEMORY.org or TODO.org in siam-farmer, its Emacs side (watch.el, cider-error.el) beyond what the Clojure sources say about them, or most of Flan's plan.org/BUILT.md outside the FFI sections. Everything below points at a file. Nothing below is 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:

  • src/game.clj, draw-tile!rl/draw-texture-pro!* with a source rect into the atlas, a destination rect on the grid, an origin, a rotation and a tint.
  • src/game.lisp, draw-tile — the same call, same six arguments.
  • src/game.clj, draw-game — again for the palette overlay, once per palette cell.

It is not in vendor/raylib/raylib.flan. I grepped for both spellings across vendor/, lib/, test/ and tools/; there is no DrawTexturePro and no draw-texture-pro anywhere in the tree.

Nothing else in the hand-written 172 does the job. DrawTextureRec takes a source rect and a position but no scale, and this game draws 16px tiles at 4x. DrawTextureEx takes a scale but no source rect, and this game draws one cell out of an atlas. The two together are exactly the two halves DrawTexturePro puts in one call, and neither half is usable alone.

Blocker. Not "awkward" — the renderer of this game cannot be expressed. The grid draw, the palette, and the tile riding the cursor are all of the drawing there is.

Cost to fix: one line. The function's signature mentions only Texture2D, Rectangle, Vector2 and Color, all four of which raylib.flan already describes, so it is a declare-c beside draw-texture-rec and nothing else.

Why it did not already land. It was importable — with FLAN_RAYLIB_H exported it came in with the other 256. But vendor/raylib/headers made the header opt-in on purpose, so that a build needs libraylib linkable and not raylib-devel installed. That property is worth keeping, and it meant the default build of this game had no renderer. (Both halves of that changed after this was written: the generated bindings and the header are committed, so the default build has every declaration and the header check is not opt-in. The rule below survives it — see the note at the end of §1.) The general rule this exposes is worth more than the one line: a function the game calls every frame should be hand-written and checked against the header, not left to the import. The import widens the surface; it should not be load-bearing.

Three more the same way, none of them blockers, all one line each:

Missing Used at Verdict
ImageFromImage sprite_atlas.clj auto-select-tiles; tilemap-blob.lisp tile-subimages Annoyance — see §3
IsWindowReady engine.clj run-game!, engine.lisp run-game — the "window already open" guard Annoyance; a defvar bool does the same thing
SetTextureFilter declared in rl.clj, called nowhere Not needed. Point is raylib's default
UpdateTexture declared in rl.clj, called nowhere Not needed
SetClipboardText sprite_atlas.clj, in a comment block only Not needed
GetMouseWheelMoveV declared in rl.clj; the atlas tool uses the scalar form, which is bound Not needed

And one enum member, not a function: raylib.flan's Key has no left-shift. Both game.clj's handle-game-input and game.lisp's do shift+1..5 to pick the tilemap, via 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 ys, 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 did not have FLAN_RAYLIB_H set was 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.

Update — the justification moved, the rule did not. There is no FLAN_RAYLIB_H any more. vendor/raylib/generated.flan holds every declaration the importer produced and is committed; vendor/raylib/raylib-5.5.h is committed beside it and headers names it directly, so the signature check runs on every build rather than on the builds that had a variable exported. The original argument — "the default build has no header, so it has no DrawTexturePro" — is therefore dead. The rule outlives it for a different reason: a per-frame call hand-written in raylib.flan is one a person transcribed, and the hand-written lines are the only declarations in the package a header can actually contradict. Everything generated agrees with the header by construction.


2. What looks like a gap and is not

This is the half of the report that should stop work rather than start it. Each of these is a Clojure habit the Lisp port already dropped, or a Flan answer that is simply spelled differently.

Persistent maps as game state — already abandoned

game.clj threads an immutable map through update-as-> (util.clj), cond->, assoc, dissoc, reduce-with-reduced. check.clj exists only to police the shape of that map at runtime, with malli, including {:optional true} keys because dissoc :editor-mode is how the editor closes.

game.lisp threw all of it away. The state is a defclass with fixed slots; update-game mutates through accessors and returns nothing; drag-mode is a slot holding nil rather than a key that comes and goes.

In Flan that is a defstruct mutated through a (Ptr Game). check.clj does not get ported — it disappears, because the compiler is already doing it and does it earlier. No feature is needed.

reduce with reduced for early exit — break covers it

Three sites: set-random-tile!'s weighted pick, handle-game-input's number-key scan, update-game's palette hit test. All three are "walk until something matches, then stop". Flan has break with loop labels. game.lisp had already rewritten all three as loop … return.

loop/recur and tail calls are not needed by this game. Nothing here recurses.

Escaping closures and capture — needed once, and trivially avoided

I checked every function-valued thing in both implementations:

  • run-game!'s :init/:update/:draw/:unload/:watch-fn#' vars in Clojure, bare symbols in Lisp. Top-level names, no environment. Flan's (Fn [...] R) takes them as-is, and fn-values.flan shows exactly this shape.
  • do-neighbors!'s do-fn — non-escaping, called and dropped.
  • auto-tile! passes do-neighbors! a literal (fn [l r c] … game …). That one captures game. fn-capture.flan refuses it by name.

The fix is to give the callback the parameter: (Fn [(Ptr Game) i32 i32 i32]), and do-neighbors forwards the game it was handed. One extra parameter. Note also that game.lisp's do-neighbors is currently dead code — auto-tile calls tile-bitset directly — so even the one capture is in a path the port has not revived.

Escaping closures are not on this game's path. Nothing stores a function with an environment; nothing needs to.

Result/try — nothing uses that discipline

Neither implementation returns errors as values. Both use the host's exception or condition system throughout. Porting this game exercises signal/error/restart-case and never wants a Result.

handler-case — one site, and handler-bind already covers it

engine.clj's reload-config! is (try … (catch Exception e (println e))). That is "log it and carry on", which is what a handler-bind clause returning normally does.

Handle and pools — nothing to pool

The texture cache is five entries keyed by path (engine.clj load-texture-once, engine.lisp *texture-cache*). There are no entities yet, no spawning, no freeing mid-frame. Handles and pools have no customer in this code.

User-written allocators, structural typing — no customer either

Nothing in either implementation parameterises over allocation, and nothing depends on a type's shape rather than its name.

Reading .edn / .data files at runtime — vendor:edn already does it

engine.lisp read-data, game.lisp load-tileset and read-bitmask read plists out of assets/*.data at startup, and reload-config! re-reads the config while the game runs. vendor/edn/edn.flan is a 561-line tokenizer with int-of / text=? / keyword=? / expect, and test/programs/edn.flan includes a hand-written struct reader as the worked example.

So this is writable today. It costs a hand-written reader per schema — two here, the tileset (:texture-path, :selected-cells) and the bitmask table — because (read-edn T bytes) is not built. Call it ~80 lines, once, at load time. See §3 for whether it should be written at all.


3. Real friction, with what the workaround costs

Ordered by how much of the game it touches.

Globals cannot hold a Vec or a Map

lib/check.ml:4039, no_move_only_global. Both hosts keep the texture cache in a global (engine.clj's texture-cache atom, engine.lisp's *texture-cache* hash table), and engine.lisp keeps *buffers* there too.

sand.flan's idiom — everything in defvar fixed arrays, the loop functions taking no state — works because nothing it holds is move-only. This game's state is not like that: tilesets, src-rects, dst-rects and the bitmask table all want a Vec or a Map.

Annoyance. Two workarounds, both cheap, and the second is better than what either host does:

  1. Hold the state in a let in main and pass (Ptr Game) to update and draw. Costs one parameter on three functions.
  2. For the texture cache specifically, a [8 CacheEntry] fixed array with a count beats a (Map string Texture2D) outright — the game loads five textures, and a linear scan of five path comparisons at load time is not worth a hash table. This is a case where the restriction pushes toward the right answer.

A Vec cannot hold a move-only element, and a Map cannot hold a move-only value

lib/check.ml:412 and :317. game.lisp's shape is tilesets = a vector of tileset structs, each of which owns tiles, src-rects and dst-rects vectors. That is (Vec Tileset) where Tileset owns three Vecs, and it is refused — recursive teardown waits on drop, which NEXT.md:484 records as deferred, not built.

Annoyance. The fix is at the inner level, not the outer one: making the outer container a fixed array changes nothing, because [4 Tileset] where Tileset owns Vecs is still move-only transitively (lib/check.ml:3831). Make tiles, src-rects and dst-rects fixed arrays with counts, and then nothing in the state is move-only at all. This game has exactly one tileset and about twenty palette cells. Cost: the palette's capacity becomes a constant instead of growing. That is not a loss at this size.

Flag it anyway, because the next thing this game grows — several tilesets, several atlases — grows straight into it.

Generics: an annoyance here, not the blocker the plan says it is

I looked for every place this game changes element type, because that is the case the project has queued generics for.

  • game.clj compute-tileset-rects / game.lisp the same — [r c] pairs to Rectangles. Twenty elements, once, at load.
  • game.clj init-game's mapv over four [keyword path] pairs to textures. Four elements, once.
  • The prelude was monomorphic per element type (sort-i32!, map-f32!, reduce-i32), so anything over [r c] pairs had no helper at all. (Generics landed after this was written: those families are one sort!, map! and reduce now, each over a type variable, so a helper does exist for a slice of pairs. It does not change the count below — three loops, five lines each, at load time.)

All three are dotimes with a push. Five lines each, at load time, run once.

The reusable-engine argument does not survive either. engine.clj is shared by game.clj and sprite_atlas.clj with two different state types, which looks like it forces run-game to be generic over the state. It does not: with the state in the game's own storage and the engine taking only (Fn [] Unit) callbacks, the engine never names the state type. sand.flan is already written that way. The frame rollback is the one piece that needs to touch the state, and the game can supply snapshot/restore as two more callbacks.

Settled by the author, 2026-09-13: fixed arrays with counts. The first branch below. So the conditional verdict in this section is now unconditional: the state fits in defvar globals, the engine takes only (Fn [] ()) callbacks and never names a state type, generics stays off this game's critical path, and drop is not needed for this version. Nothing in the language had to be built for it — fixed arrays, counts and slices all already work. What is left is writing the game.

Carry forward the one flag §3 raises: the next version, with several tilesets and several atlases, grows straight into drop and into (Vec T) where T owns a Vec.

This verdict is conditional, and the condition is one decision. Both it and the globals workaround above hang on the same pivot, so it is worth stating once, flatly: nothing in this game's state needs to be move-only. Make the inner collections fixed arrays with counts, and then the state fits in defvar globals, the engine takes only (Fn [] Unit) callbacks and never names a state type, and generics stays off the critical path. Keep Vecs in a let-bound Game struct passed as (Ptr Game) instead, and run-game names Game, two programs with two state types need two engines, and generics is back on the critical path immediately. The recommendation is the first branch.

On that branch: generics is worth building, and it is not what is standing between Flan and this game. Saying otherwise would send the work in the wrong direction. If the goal is siam-farmer running, generics is not the next thing.

A bad index ends the process, and the frame loop is built on surviving one

This is the finding with the widest consequences, and it is about the dev loop rather than the language.

Both hosts survive a bad frame and keep the window open:

  • engine.clj run-game! snapshots every IntGrid at the top of the frame, catches Throwable, restores the grids, and parks in a draw-only loop until the error is cleared.
  • engine.lisp run-frame wraps update and draw in a restart-case offering retry-frame, skip-frame and reinit, each of which rolls the grids and a MOP shallow-copy of the state object back.

Both files carry a deliberate blow-up on the E key (game.clj handle-game-input, game.lisp the same) whose only purpose is exercising that path.

Flan has the shape already — sand.flan's main loop is restart-case around (agent/poll) and (game-update) with a continue restart, which is skip-frame under another name. Two things are missing behind it:

  1. The rollback. sand.flan's continue abandons the frame but restores nothing. This game's grids are mutated in place by set-tile/delete-tile, so an abandoned frame leaves half-written state. In Flan this is easier than in either host — fixed arrays are values, so (set backup grid) is the whole of snapshot! and there is no sb-mop:class-slots walk to write. It just has to be written.

    Done, 2026-09-13. Written, in test/programs/frame-rollback.flan: snapshot at the top of the frame, restore in the continue clause, over one fixed array and one struct — engine.clj's grids plus engine.lisp's shallow copy of the state object, two sets here because both are values. The ordering against defers is the decision in it, and item 6 below records it. Three acceptance rows, plain, -O0 and dev.

  2. A bounds failure is not a condition. runtime/flan_rt.c flan_bounds_fail prints and calls rt_die, which is exit(134). No handler runs, no restart is offered, and lib/dev.ml answers every subsequent request with "the program exited; restart flan dev".

    Fixed, 2026-09-13. It signals BoundsError with error now, and dies with that same message only if nothing answered. The site establishes no restart — nothing a handler can do makes a bad index good, so there is no attempt to re-run — and what answers it is the restart the program already had, which is exactly the frame loop's continue this section is about. BUILT.md has the reasoning, test/programs/bounds-condition.flan has the handled case, and test/programs/dev-break-bounds.flan drives the break loop over an unhandled one from the editor's side — stopped, restarts listed, continue taken, session intact. Item 1 below, the rollback, is now the whole of what is left here.

    That is not theoretical for this game. game.clj's update-game computes row and col straight from the mouse position and indexes the grid with them, with no bounds check anywhere. game.lisp added in-bounds-p and calls it in update-drag — the author hit this and fixed it in the port. But the Clojure version survived the bug because a bad frame was recoverable, and under Flan the same bug is the end of the session.

Not a blocker for writing the game — the game can be written, and the check can be written. It is a hole in "never restarting", and this game walks into it by the most ordinary route there is: a mouse coordinate one pixel outside the window.

Worth separating honestly: the (+ 1 :asdf) blow-up both hosts carry is a type error, and Flan rejects it at compile time. Part of that rollback machinery is answering a dynamic-language problem and is not a gap. The out-of-bounds path is the part that survives the port, and it is the part that matters.

The watch buffer has no equivalent — closed, Tier 1 item 5

watch.clj is a pull-based watch: the game drops a snapshot of labelled values into an atom once per frame, and Emacs polls it on its own timer, so the watch rate is decoupled from the frame rate and nothing is pushed over nREPL. spy records one value per label; spy-num keeps count/min/max/last/mean in a double-array so it survives being called thousands of times a frame without allocating. game.clj's watch-game publishes the mouse position and the whole state minus the three big collections every frame.

Flan's inspector is stopped-only: lib/dev.ml globals_op refuses a running program by name — "globals are read against a stopped stack, and the stack is what decides which of them to show". That is the right design for a break loop and it is not what this is. Watching a value while the game plays at 30fps is a different tool.

Annoyance, and a heavily-used one. The author built this deliberately (the docstring argues the design), built the Emacs side for it, and built a second numeric variant for hot loops. A game that stops to be inspected is not the same workflow as a game that shows you a number while you drag the mouse.

Whether flan dev's eval against a running program could serve it — it does not gate on Running the way globals_op does — I did not test, and should not be assumed.

Closed. Both halves are built. M-x flan-watch is the buffer, fed by a table the program writes and Emacs reads as memory — pushed rather than polled, so the values are as fresh as the last frame and are still there while the program is stopped. watch-num-i64 / watch-num-f64 are the spy-num half, keeping count/min/max/last/mean per slot with no formatting on the write path. M-x flan-watch-ghost-mode shows the same values inline at the call that wrote each one. The finding above stands as the reason the inspector was not the answer: it is stopped-only, and this is a different tool. See Tier 1 item 5 and emacs/MANUAL.md.

Offline image tooling

sprite_atlas.clj auto-select-tiles carves the atlas into 16x16 subimages and keeps the ones that are not fully transparent. tilemap_blob.clj / tilemap-blob.lisp carve a tileset the same way and sample eight edge pixels per tile to compute an autotiling bitmask. Both go through ImageFromImage, which is not bound.

Not a gap, mostly a rewrite. GetImageColor is bound, and Image.data is a (Ptr u8), so both jobs read better against the whole image with computed coordinates than against a subimage per tile — the bitmask sampler in particular is eight get-image-color calls at offsets derived from the tile's origin, and never needs a subimage at all. ImageCrop is bound but mutates in place and there is no ImageCopy, so the literal transcription is the one that does not work.

sprite_atlas.clj also opens a java.awt.FileDialog to choose where to save. Nothing in Flan or in raylib 6.0 offers that. Workaround: write to a fixed path under assets/. Tool-only, and the tool is run by the author.

String formatting

sprite_atlas.clj's HUD draws (format " X: %d" (int mx)) every frame. Flan has append-i64!, concat, join and format-f64, and no printf. Cost: a few lines and a reusable buffer per HUD string, since doing it naively allocates per frame. Small, and only the atlas tool draws text today.

reload-config! may not want porting at all

engine.clj re-reads game-config.edn from disk while the game runs so the config can be edited live. Flan's answer is compile-time embedding plus flan dev recompiling into the running process, which reaches the same place with no file watcher, no runtime parser, and no window where the file on disk and the value in memory disagree. This is a case where porting the mechanism would be porting the workaround.


4. What Flan does better than either host

Worth recording, because these are places the game gets shorter rather than longer.

  • Conditions and restarts, natively. engine.clj reaches them by printing a SIAM-REPORT-EXCEPTION marker line, stashing the throwable in an atom, and having cider-error.el watch the REPL output and rethrow it over nREPL so it lands in a stacktrace buffer. That is a marker line, an atom and an Emacs Lisp handler simulating what engine.lisp gets from restart-case in eight lines. Flan is on the Lisp side of that, with typed restarts, and dev-break.flan shows the editor driving them.

  • Frame snapshot is an assignment. engine.lisp walks sb-mop:class-slots to shallow-copy the state object, with a comment explaining that it copies into the existing object so a REPL reference stays live. In Flan the state struct and the fixed arrays are values; (set backup state) is the whole thing, and there is no aliasing question to answer.

  • check.clj does not get ported. 56 lines of malli schema, a :closed true map, and a compile-time flag to switch it off in release — all to catch a typo'd key. A struct field is checked by the compiler, earlier, for free, and the release flag has nothing to switch off.

  • Asset paths stop being a hazard. engine.lisp resource-path exists because raylib resolves paths against the process working directory, which under SLY is wherever the inferior Lisp started, and a miss is silent — LoadTexture hands back a texture with id 0. The function resolves against the ASDF system directory and probe-files it, and load-texture-once checks for id 0 afterwards anyway. Flan's compile-time embedding of a file or a directory, plus the bound LoadImageFromMemory and LoadTextureFromImage, removes the failure mode instead of detecting it.

  • num-keys indexing. game.lisp's key loop reads (aref *num-keys* n) over a literal vector of keywords. Flan's defenum Key resolves a keyword against its members at compile time and a typo is an error there.


siam-farmer vendors raylib 6.0 (vendor/raylib-6.0/include/raylib.h, RAYLIB_VERSION "6.0"). Flan pins 5.5vendor/raylib/link names libraylib.so.550 and build-web.sh pins the web archive to tag 5.5, both with comments explaining that two targets built from different raylibs would disagree about layouts silently.

I diffed every struct on this game's path against the 6.0 header: Vector2, Color, Rectangle, Texture/Texture2D, Image, Font, GlyphInfo. All seven are byte-for-byte identical to raylib.flan's defstructs. Field order, field count and scalar widths all match.

Of the C names rl.clj declares, I checked nine directly against the vendored header — DrawTexturePro, ImageFromImage, GetImageColor, CheckCollisionPointRec, DrawTextureEx, SetTextureFilter, IsWindowReady, SetClipboardText and GetMouseWheelMove — and all nine exist in 6.0 under the same name with the same signature. I did not sweep the full ~50. Those nine are the ones on the game's own path; the remainder is unchecked and should not be read as verified.

Enum values, the other half of what link says raylib.flan carries. KEY_ZERO=48 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 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 was §1: DrawTexturePro, then ImageFromImage and IsWindowReady. All three are bound.


6. Ranked: what to build next

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. Done, 2026-09-13.

  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. One entry, 340. §5 records that nothing else in the enum was missing.

  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 ys out of one sheet, and the sheet re-read afterwards to show it was not destroyed.

  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 had no FLAN_RAYLIB_H and the default build has to be able to draw. (That justification is obsolete — the bindings and the header are both committed now — but the rule stands on the other leg: see the update at the end of §1.)

Tier 1 — language and tooling, in the order that unblocks the most of this game

  1. A bounds failure should stop the program, not end it. Done, 2026-09-13. It signals BoundsError, reaches handlers and the break loop, and is fatal only when nothing answers. Vec's checks went with it, since (at v i) and (at arr i) are one form in the source. No restart is established at the site: retry exists for allocation and for files because those attempts are repeatable, and this one is not — so what answers a bad index is the continue the frame loop already offered, which is the restart this document was pointing at all along.

    Two consequences worth carrying forward. Defer had to be decided rather than inherited — an answered bounds failure runs the function's defers, because it leaves through the same unwind path a return does; an unanswered one still runs none. And item 6 below got more valuable, not less: now that a bad index lands in continue instead of ending the process, the missing half is the rollback, because an abandoned frame leaves the grid half-written. That is the next thing on this list.

  2. A watch for a running program. Done, 2026-09-13. The spy half — a pushed table of labelled values, read as memory while the program runs, and still readable while it is stopped — was already built, along with the watch buffer and the inline ghost text. What landed today is the spy-num half, which is the part this item called least obvious and most valuable, and it is the part that was missing.

    A slot keeps five numbers: count, min, max, last, mean. Each answers a question you can ask without building a query — n is the first thing wrong when a loop is wrong, the range is what one sample can never show you, last is what the scalar watch would have given you, and the mean is carried as a sum and divided at read time because a mean accumulated as a mean drifts. A small ring of the last N samples was the other candidate and loses: N out of 91,200 is a sample of the tail of the loop rather than of the loop. The write path does no formatting — a sample is a load, five compares and the slot's seqlock, and the listener thread renders once per editor tick, which is the whole reason spy-num exists rather than a second spy.

    One deliberate divergence from watch.clj, recorded because it is a real disagreement and not an oversight: there the stats are cumulative until reset-spies!. Here the window is since the editor's last tick. Cumulative min and max reach the session's extremes within a few seconds of play and then never move again, so the two most useful of the five go dead exactly when you start interacting with the thing you are debugging — and this tool exists to show you a number while you drag the mouse. Reset is its own message rather than a side effect of reading, so anything that polls cannot shorten the window under the editor that owns it.

    Full reasoning in BUILT.md, "A hot loop keeps five numbers, and the window is the editor's". One thing this did not need and is worth saying: no arm in check.ml. The accumulator is reached by the same plain declare-c the scalars use, so the (watch "hp" hp) form is still unbuilt and still only wanted for composites.

  3. Frame rollback in the engine pattern. Done, 2026-09-13. Not a language feature and nothing was added to the language: test/programs/frame-rollback.flan is the worked example, snapshot at the top of the frame and restore in the continue clause, over one fixed array and one struct. Two sets each way. That is the whole claim about values — engine.clj walks every IntGrid and engine.lisp walks sb-mop:class-slots, and here there is nothing to walk.

    The ordering against defers was the decision, and both orderings compile. An answered bounds failure runs the abandoned function's defers, innermost-first, before the restart clause body starts (item 4 above, spec-conditions.md §5). So:

    • restore in the continue clause — chosen. It is the last write on the abandoned path and therefore needs no agreement with what any defer did on the way out. A defer that writes into the snapshotted state is simply overwritten, which is what "the frame did not happen" means.
    • restore in a defer inside the frame function — rejected, and it is the silent one. A defer runs on the ordinary return path too, so that version rolls back the frames that succeeded. Nothing errors; the game stops advancing.

    The test pins the ordering with two numbers rather than asserting it in prose: a counter inside the snapshot, written by the frame's defer, reads its pre-frame value, while a counter outside the snapshot proves the defer ran. And there is a negative control — the same bad frame with a continue that only counts — because "state equals snapshot" passes trivially on a program that wrote nothing.

    One edge worth carrying: the snapshot covers plain values only. If a defer frees a resource and the snapshot holds a pointer or handle to it, restore resurrects a dangling one. Value state in the snapshot, resources in the defers, no overlap.

  4. drop, or recursive teardown. Unblocks (Vec T) where T owns a Vec, which is game.lisp's tileset shape exactly. Fixed arrays sidestep it at this size, so this is about the version of the game with several atlases, not this one. NEXT.md:484 already has it deferred with reasons; nothing here argues with that, only records a customer.

  5. Generics. Real, and worth building — but for this game it is five-line loops at load time, and the reusable-engine argument for it dissolves once the state lives in the game rather than in the engine. Do not sequence it ahead of items 46 on this game's account.

  6. (read-edn T bytes). vendor:edn already makes the asset readers writable; this removes ~80 lines of hand-written cursor walking for two schemas. Convenience, and it competes with compile-time embedding, which may be the better answer for both files anyway.

Not ranked, because this game does not need them: escaping closures and capture (one site, fixed by one parameter), Handle and pools (nothing to pool), Result/try (neither host uses that discipline), handler-case (one site, handler-bind covers it), loop/recur and tail calls (nothing recurses), user-written allocators, structural typing.