611 lines
35 KiB
Markdown
611 lines
35 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 is importable — with `FLAN_RAYLIB_H` exported it
|
||
comes in with the other 256. But `vendor/raylib/headers` says the header is opt-in on
|
||
purpose, so that a build needs libraylib linkable and *not* raylib-devel installed. That
|
||
property is worth keeping, and it means the default build of this game has no renderer.
|
||
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 does not have `FLAN_RAYLIB_H` set is the default build, and the default build
|
||
has to be able to draw. The test that goes with the rule is a link check, which is cheap
|
||
and is all a GL-context call can have.
|
||
|
||
---
|
||
|
||
## 2. What looks like a gap and is not
|
||
|
||
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 is monomorphic per element type (`sort-i32!`, `map-f32!`, `reduce-i32`), so
|
||
anything over `[r c]` pairs has no helper at all.
|
||
|
||
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
|
||
|
||
`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.
|
||
|
||
### 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 has no
|
||
`FLAN_RAYLIB_H` and the default build has to be able to draw.
|
||
|
||
### Tier 1 — language and tooling, in the order that unblocks the most of this game
|
||
|
||
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.
|