932 lines
54 KiB
Markdown
932 lines
54 KiB
Markdown
# 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 4–6 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
|
||
|
||
> **Closed, 2026-09-14.** It was a checker change and it is made: `agrees` in
|
||
> `lib/cimport.ml` now has the pointer arm its own comment had been listing among the
|
||
> differences it accepted. `vendor/raylib/raylib.flan` carries
|
||
> `get-codepoint-previous-raw` — the exact declaration this section says it wanted — and
|
||
> a `get-codepoint-previous` wrapper over it that takes the bytes and an offset;
|
||
> `generated.flan` no longer carries the `string`-faced version at all, because a binding
|
||
> that is wrong for the only direction it reads in is worse than no binding. The example's
|
||
> `step-back` is one call to it, the continuation-byte walk is gone, and the expression
|
||
> below now prints 12356 and 3. The section is kept as written; the reasoning is the part
|
||
> worth having.
|
||
|
||
|
||
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
|
||
|
||
> **Half closed, 2026-09-14, and the other half turned out to be somewhere else.** The
|
||
> header check does now accept it: a C `void *` is opaque about *what* it points at, so
|
||
> `ptr_agrees` lets a `(Ptr` anything `)` stand against one, and that was the refusal this
|
||
> section hit. What the refusal was masking is that `lib/shim.ml` will not take a second
|
||
> `declare-c` for a C symbol it already has — a shim emits one C prototype per
|
||
> declaration, and two prototypes for `UpdateTexture` that disagree about a parameter type
|
||
> is a C file that does not compile. So the natural binding fix named below is refused
|
||
> after all, by a rule that is right.
|
||
>
|
||
> Its own message says what to write instead — "another Flan name for it is a defn" — and
|
||
> `vendor/raylib/raylib.flan` now has `update-texture-colors`, a one-line defn over the
|
||
> generated `update-texture` that spells the conversion as `(addr (.r pixels))`. The
|
||
> `(Ptr u8)` face has to stay the declared one, because `(.data im-copy)` is already a
|
||
> `(Ptr u8)` over the same bytes and `(Ptr u8)` → `(Ptr Color)` is the direction that
|
||
> cannot be written. The example's call site is `(rl/update-texture-colors texture pixels)`
|
||
> and the slice-and-index is gone; the cast still exists, once, with a name and a comment
|
||
> on it. **What is still open is the general thing this section is about** — a pointer
|
||
> reinterpretation — and it is not a checker arm.
|
||
|
||
|
||
`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;
|
||
`handoffs/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 were one arm in `lib/cimport.ml`'s `agrees` away from
|
||
being ordinary binding work. That arm was written on 2026-09-14 and §A.1 is now ordinary
|
||
binding work; §A.2 turned out to have a second, better reason behind the first, and the
|
||
boxes at the head of each section say what happened.
|
||
|
||
## 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.
|