flan/PORTING.md

898 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `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 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 `Vec`s, 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 `Vec`s
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
`Rectangle`s. 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 `Vec`s 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 `set`s 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
> *un*handled 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-file`s 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.
---
## 5. The raylib binding, checked against what the game actually links
siam-farmer vendors raylib **6.0** (`vendor/raylib-6.0/include/raylib.h`, `RAYLIB_VERSION
"6.0"`). Flan pins **5.5**`vendor/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 `defstruct`s.** 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 `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.**~~ 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
4. ~~**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.
5. ~~**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.
6. ~~**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 `set`s 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.
7. **`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.
8. **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.**
9. **`(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.
---
# Porting the raylib examples — the last two
`text_codepoints_loading` and `textures_image_processing`, the two left on the list, both
ported and both building: `examples/text-codepoints-loading.flan` and
`examples/textures-image-processing.flan`, each with a headless acceptance case beside it
in `test/programs/`. They were picked because they were expected to stress two corners the
earlier rounds had not — Unicode and codepoint arrays on one side, CPU-side pixel buffers
and in-place mutation on the other — and both expectations were right, though not in the
places anyone guessed.
**Coverage.** Read in full: both upstream C sources in `~/Repositories/raylib`, every
`.flan` in `examples/`, `vendor/raylib/raylib.flan`, `generated.flan`, `bindings` and
`headers`, the `PixelFormat` and `Font`/`GlyphInfo` halves of `raylib-5.5.h`, `test/dune`,
the raylib blocks of `test/test_acceptance.ml`, `test/programs/raylib-image.flan` and
`test/programs/virtual-controls-headless.flan`, and the string-crossing half of
`lib/shim.ml` and `lib/cimport.ml`. Everything below was run rather than reasoned about;
each finding in §A names the command that produced the failure.
## A. The two gaps
### A.1 `GetCodepointPrevious` cannot be called, and closing it is a checker change
This is the real find of the round, and it is general: **a C function that reads
*backwards* from the pointer it is handed cannot be given a Flan `string`.**
`lib/shim.ml` is explicit about why — `Pstr` is "ptr+len in, a NUL-terminated copy out",
and the wrapper builds that copy for the duration of the call. Forwards, that is invisible
and free. Backwards it is not: the bytes in front of the copy belong to the allocator, so a
function that steps back from the pointer reads memory that has nothing to do with the
text.
It does not crash, which is the bad part. Run headless:
```flan
(let [text "いろはに"
b (bytes text)
sz 0]
(println (rl/get-codepoint-previous (string (slice b 3 (len b))) (addr sz)))
(println sz))
```
That prints `0` and `0`, where the answer is 12356 (い) and 3. Zero is also what
`GetCodepointPrevious` returns for a genuinely malformed sequence, so there is nothing in
the result to distinguish "Flan handed you a copy" from "your text is broken". The same
expression with `get-codepoint-next` is correct, because that one only reads forwards.
That is the whole of the difference.
**The code I wanted to write, and what stops it.** The obvious repair is the one
`raylib.flan` already uses twice — a hand-written `-raw` declaration that says `(Ptr u8)`
and means it, as `load-image-from-memory-raw` and `load-font-ex-raw` do:
```flan
(declare-c get-codepoint-previous-raw
[text (Ptr u8) codepoint-size (Ptr i32)] i32
"GetCodepointPrevious")
```
with `(addr (at b i))` for the interior pointer. It does not work, and the reason is not
the FFI. `lib/cimport.ml` maps `const char *` to `string`, and `agrees` — the function that
decides whether a hand-written declaration and the header say the same thing — accepts
exactly one difference, an enum against a 32-bit integer. `(Ptr u8)` against `string` is not
it. Adding the line above and building *anything*, not only regenerating, gives:
```
vendor/raylib/generated.flan:233:12: the declare-c of get-codepoint-previous-raw
disagrees with vendor/raylib/raylib-5.5.h: parameter text is (Ptr u8) and the
header says string (const char *)
```
`headers` is read on every build, so the binding cannot be added without relaxing the
header check, and relaxing the header check is a change to the compiler. **Feature freeze;
not built.**
**Worth flagging beside it: `cimport.ml`'s own comment promises this and the code does not
deliver it.** The list of "three differences that are expected and are not reported" at
`lib/cimport.ml:1291` includes
> - a `(Ptr T)` where the header says `T *` and the hand-written line chose something more
> specific for a reason it recorded.
`agrees` implements the enum arm and nothing else. Either that bullet describes an
intention never written, or the rule was lost in a refactor. Whichever it is, the paragraph
is already the design note for the fix: a third arm in `agrees` accepting a hand-written
`(Ptr T)` against a header pointer, which would let this binding and the one in A.2 both be
written honestly.
**The workaround, and it is a good one.** UTF-8 is walkable backwards without asking
anybody: a continuation byte is `10xxxxxx`, so stepping back over continuation bytes lands
on the lead byte of the previous codepoint, and `get-codepoint-next` from *there* says what
it is — forwards, where the copy costs nothing. That is `step-back` in the example, four
lines. `test/programs/raylib-codepoints.flan` pins it by walking the poem forwards, walking
it backwards, and checking that the two are reverses of each other and that the forward one
is what `LoadCodepoints` says the text contains.
It is fewer instructions than the call would have been. **But the finding is not "this one
function"** — it is that the direction a C function reads in is invisible in its signature,
and Flan's string crossing makes half of those directions silently wrong. Every future
binding over a `const char *` cursor meets it.
### A.2 There is no cast between pointer types
`textures_image_processing` hands the pixels `LoadImageColors` returned straight to
`UpdateTexture`. In C both are pointers and nothing has to be said. Here
`load-image-colors` answers `(Ptr Color)` and `update-texture` takes `(Ptr u8)` — the
header spells that parameter `const void *`, and `cimport.ml` has to render a `void *` as
something — so the call is refused:
```
expected (Ptr u8), found (Ptr rl/Color)
```
A hand-written `(declare-c update-texture-colors [texture Texture2D pixels (Ptr Color)] …
"UpdateTexture")` is the natural binding fix and is refused by the same header check as
A.1, with the same message. **Not built.**
**Workaround, and it is legitimate rather than a trick:** the address of the first field of
the first element is the address of the buffer.
```flan
(rl/update-texture texture (addr (.r (at (slice-from-ptr pixels n) 0))))
```
`Color`'s first field is `r`, a `u8`, at offset 0. It compiles, it is the right address,
and it says out loud what C's implicit conversion was doing quietly. It is also ugly, and
the ugliness is the report: a `(Ptr u8)` view of a typed buffer is something an FFI wants
often — `void *` appears thirty-odd times in raylib.h alone.
Two smaller notes on the same call. `(.data im-copy)` is already a `(Ptr u8)` over the same
bytes, so the whole round trip is avoidable; the example keeps it because it is what the C
does and because nothing else in the corpus exercises
`LoadImageColors`/`UnloadImageColors`. And `slice-from-ptr` is what makes any of this
readable — it is the one form that turns a raylib pointer plus a raylib count into
something with a length, and it was reached for three times across the two files.
## B. What was expected to be a gap and was not
**UTF-8 in a string literal works, end to end, with nothing added.** This was the round's
open question and the answer is clean. The reader takes the bytes, the object file carries
them, the shim hands them over, and `LoadCodepoints` decodes the 54 codepoints of the Iroha
into the 49 distinct ones the atlas is built from. Nothing in Flan claims to know what a
character is — a `string` is bytes and `(bytes s)` / `(string b)` say so in both directions
at no cost — and for this job that is exactly the right amount of opinion. There is a
`valid-utf8?` in the prelude and this example never needs it.
**An interior pointer into a string has an idiom already.** `(string (slice b off (len b)))`
is the C's `char *ptr` and compiles to nothing: a `string` and a `[u8]` are the same two
words. Every forward-reading `const char *` entry point is reachable that way.
**`load-font-ex` needed nothing.** It takes a `[i32]` of codepoints, takes the
pointer-and-count apart itself, and uses a zeroed `defvar` as the null pointer that means
"the default ASCII set". It was written for this call before anything called it, and the
call fit it exactly.
**`and` short-circuits, which `step-back` depends on.** The backward walk's loop condition
is `(and (> i 0) (continuation? (at b i)))`, and at the start of the text `i` is -1 — so if
`and` were a strict function rather than a form, the guard would not save the `(at b -1)`
beside it and the first press of LEFT would signal `BoundsError`. It is a form and it does
short-circuit; `test/programs/raylib-codepoints.flan` has a row for it, because the walk
itself never reaches that offset and the claim is about the language rather than about the
example.
**Fixed arrays were the right shape for both.** A codepoint table with a count, a
`[9 Rectangle]` of toggle buttons, a `[9 string]` of labels. The §3 finding from the
siam-farmer report — a global cannot hold a `Vec`, and fixed arrays with counts are usually
the better answer anyway — held again, in two more programs, with no friction at all.
## C. Where Flan was better than the C
**The upstream deduplication reads out of bounds, and Flan will not perform it.**
`CodepointRemoveDuplicates` compacts its array by shifting the tail down over each
duplicate:
```c
for (int k = j; k < codepointsNoDupsCount; k++) codepointsNoDups[k] = codepointsNoDups[k + 1];
```
On the first duplicate `codepointsNoDupsCount` is still the full count, so at `k = N-1`
that reads element `N` of an `N`-element allocation. C does not notice: `RL_CALLOC` has
slack and the value is overwritten immediately. A literal transcription signals
`BoundsError` and stops the frame. The port is a build-up instead — scan the output, append
if absent — which is shorter, has no shifting in it, and keeps first-seen order exactly as
the C's version does. This is Tier 1 item 4 of the report above catching a real upstream bug
in the first program that met it.
**The C's cursor walks off both ends of its string,** and the port clamps. `ptr += size`
with RIGHT held runs past the terminator; `ptr -= size` with LEFT held runs in front of the
literal. Same class of thing, same outcome: what C lets through is what Flan makes you
decide about.
## D. What was added, and what was deliberately not
**Added — `PixelFormat`, a 24-member `defenum` in `vendor/raylib/raylib.flan`.**
`ImageFormat` moved from the generated half to the hand-written one (`exclude ImageFormat`
in `bindings`), and `enum PixelFormat PIXELFORMAT_` maps the members so all 24 values are
compared against `raylib-5.5.h` on every build. Two of them need a `constant` line, because
raylib spells the ASTC block sizes `ASTC_4x4` with a lowercase `x` where every other letter
in that enum is upper — the same narrow exception `GESTURE_DOUBLETAP` already had. This is
the trade `TextureFilter` and `MouseCursor` already made, for the same reason: the C is
`int newFormat` and exactly one of twenty-four conversions is the one a program meant.
It paid for itself in the test rather than at the call site. `ImageColorGrayscale`
reallocates into a one-byte-per-pixel buffer, so the working image's `format` goes from 7 to
1 mid-run, and `raylib-image-processing.flan` asserts that it does. Before the enum, that
row was two integers with nothing to say about them.
**Corrected — a wrong comment.** `raylib.flan`'s `image-from-image` said "There is no
ImageCopy in 5.5". There is: `raylib-5.5.h:1348`, and `generated.flan` has had it bound all
along. The note now says which of the two is the better call for duplicating a whole image.
**Not added — the two assets, and this is the one open decision.** Both upstream examples
load a file out of `resources/` and neither file is in this tree.
- `parrots.png` is listed in raylib's own `examples/textures/resources/LICENSE.md` with no
author and no licence — a `❔` in both columns. It is not a file to copy into somebody
else's repository on a sleeping author's behalf. The image-processing example generates
its source picture instead, as the three textures examples already here do. What the
generated image has to be is decided by the filters rather than by taste: asymmetric in
**both** axes, or the vertical and the horizontal flip are indistinguishable and the port
looks broken, and carrying sharp edges, or the Gaussian blur has nothing to work on.
- `DotGothic16-Regular.ttf` is SIL OFL 1.1 and therefore redistributable, but it is 2 MB,
and whether a compiler repository whose only binary asset is a 1 KB PNG should grow a 2 MB
font is a call about the repository rather than about this port. Left to the author;
`HANDOFF-raylib-ports.md` carries it as the open question. The example looks for it under
`examples/resources/`, says on screen when it is not there, and runs either way — raylib
answers a missing path with the default font, whose glyphs are ASCII, so the kana draw as
boxes and the program explains itself rather than looking broken.
**Neither example needed a language feature.** Nothing below the two refused bindings in §A
was blocked at all, and both of those are one arm in `lib/cimport.ml`'s `agrees` away from
being ordinary binding work.
## E. Testing
Both examples build. `flan build` on each is the only direct proof there is: `test/dune`
globs `examples/*` so that imports resolve, and does not compile them.
Both now also have a headless half in the acceptance table, gated on `ldconfig` finding
libraylib exactly as the existing raylib cases are, each run twice — plain and `-O0`:
- `test/programs/raylib-image-processing.flan` imports the example and runs its nine filters
over its pixels. It is the first case in the corpus to exercise the **in-place** half of
the Image surface: every filter takes a `(Ptr Image)` and rewrites the buffer under it,
and two of them free the old buffer and install a new one. `raylib-image.flan` is entirely
by value and cannot reach that. Grayscale, invert, tint, contrast, brightness and the two
flips are pinned to the byte; the blur is pinned structurally — size and format survive, a
pixel outside a red rectangle reddens, the inside stays red-dominant — because the exact
kernel is raylib's business and pinning a blurred byte buys a test that goes red when
raylib improves.
- `test/programs/raylib-codepoints.flan` imports the other example and pins three separate
things: that the literal survived (49 distinct of 54, a fact about the Iroha and nothing
else), that the forward and backward walks are reverses of each other (which is what
`step-back` is for), and that the forward walk agrees with `LoadCodepoints` element for
element — the outside opinion, because the second claim alone would pass if both walks
were wrong in the same way.
Both rows were confirmed to go red when deliberately perturbed, which is the only way to
know an acceptance case is wired in at all. `dune test --root .` is green.