diff --git a/PORTING.md b/PORTING.md new file mode 100644 index 0000000..d88c68e --- /dev/null +++ b/PORTING.md @@ -0,0 +1,483 @@ +# 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 + +### `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. + +--- + +## 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. + +**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. + +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". + + 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` is absent from the `Key` `defenum` entirely, which is the §1 note, not a +skew. + +So the version skew is not a correctness problem for this game today. It is still worth +closing, because the whole point of `headers` is that a silent disagreement is the failure +mode, and "I checked by hand once" is exactly the state `headers` was built to replace. + +The binding's real gap against `rl.clj` is §1: `DrawTexturePro`, then `ImageFromImage` and +`IsWindowReady`. + +--- + +## 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. Do these first. + +1. **`declare-c draw-texture-pro`.** Unblocks the entire renderer. Without it this game + has no draw call. One line, plus an acceptance case that makes raylib compute with the + source rect so a permuted `Rectangle` goes red. + +2. **`left-shift` in the `Key` `defenum`.** Shift+1..5 picks the tilemap in both + implementations, and the member is not there. One entry, and without it that input is + unwritable. + +3. **`declare-c image-from-image` and `window-ready?`.** The atlas tool and the engine's + reentrancy guard. Two lines. + +4. **Decide the rule the first item exposes.** A function the game calls every frame + should be hand-written and header-checked, not left to the opt-in import. Worth writing + down, because the next game-shaped program will find the next `DrawTexturePro`. + +### 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.** Route `flan_bounds_fail` + through the condition machinery so it reaches the break loop with the restarts that are + on the stack, instead of `exit(134)`. This is the single largest gap between what Flan + promises and what this game would experience, and the game reaches it through the most + ordinary path in it — a mouse coordinate outside the window, which `game.lisp` had to + add `in-bounds-p` to survive. Everything else here is a workaround with a known cost; + this one ends the session. + +5. **A watch for a running program.** `watch.clj` + `watch.el` + `spy` + `spy-num` is a + tool the author built deliberately and uses constantly, and the stopped-stack inspector + is a different tool for a different moment. Given that the dev loop is the priority, + this outranks every language feature below it. The numeric accumulator for hot loops is + the part that is least obvious and most valuable. + +6. **Frame rollback in the engine pattern.** Not a language feature — the pieces are all + there (`restart-case`, struct assignment, fixed arrays as values). What is missing is + the worked example showing `snapshot`/`restore` callbacks alongside the `continue` + restart, the way `sand.flan` is the worked example for the loop. A page of code and a + test program, and the "never restarting" claim gets materially stronger. + +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.