flan/sand.flan
Joseph Ferano 05676f3181 The two bindings nothing was calling, found by listing rather than by reading
An audit over every public name in raylib.flan against every file that calls
one turned up draw-circle-v and load-texture-from-image with no call site at
all — bound, linked, and never once executed, which is the state the parent
commit already made a rule about. Reading the diff had not caught either.

load-texture-from-image now has the only call site it can have: sand.flan
loads brush.png a second time as an Image, mirrors it in RAM, and uploads
that. The two badges sit side by side, so a flip that did nothing or an
upload that took the unedited buffer shows as two identical sprites rather
than as nothing.

draw-circle-v fills the dot at the world cursor's centre, beside the pixel
that was already there — both Vector2 forms, so both land where the ring's
centre is rather than where an integer cast would have put them.

Still uncalled and not this lane's to invent a use for: key-released? and
mouse-button-pressed?.
2026-09-11 19:46:44 +07:00

325 lines
16 KiB
Plaintext

;;;; Falling sand — Flan port of the Odin/Janet/Lisp/jank versions in ~/Development/fnm.
;;;;
;;;; THE SECOND ACCEPTANCE PROGRAM — build sequence milestone 4. calc-me.flan
;;;; comes first: sand cannot run at all until raylib FFI, keyword->enum
;;;; coercion and a window exist, and none of those should be on the critical
;;;; path to "the language runs something".
;;;;
;;;; It is tested twice: headless (N frames, hash the grid — the version CI runs
;;;; on native and wasm32) and interactive at 120 fps. This file is the
;;;; interactive half; the simulation itself lives in sand-sim/ so the headless
;;;; half can have it without linking raylib. See sand-sim/sim.flan for why that
;;;; split exists, and test/programs/sand-headless.flan for the other driver.
;;;;
;;;; Note what it still deliberately does not use: no Vec, no Map, no generics,
;;;; no user-written macros, no conditions, no allocator other than the stack
;;;; and static storage.
;;;;
;;;; Notation reminders (see plan.org and spec-memory.md):
;;;; [n T] fixed array, length n, element T — a VALUE, copies
;;;; [T] slice, ptr+len, non-owning (Vec T) owning, move-only
;;;; (Ptr T) pointer (Handle T) generational handle
;;;; types are inline name/type pairs, as in `let` and `defstruct`
;;;; an omitted return type means Unit
;;;; lowercase in a TYPE position is a type variable; in a LENGTH position
;;;; it is an ordinary compile-time value, so [rows [cols u32]] is unambiguous
(import rl "vendor:raylib") ; directory = package; declaration optional
(import sim "sand-sim") ; no collection prefix: relative to this file
(import agent "vendor:agent") ; the dev agent: redefinitions, installed below
;; The brush sprite: a 16x8 sheet of two 8x8 frames, the ring drawn while the
;; mouse is idle and the blob while it is painting. It is here because the
;; texture calls cannot be in the acceptance table at all — loading one needs a
;; GL context — so the only way they are exercised is by running this.
(defvar brush rl/Texture2D)
(defvar brush-ok bool)
;; The same sheet a second time, mirrored on the CPU before the GPU ever sees
;; it. That is what the Image family is for, and this is its only call site:
;; nothing headless can make a texture, so load-texture-from-image would
;; otherwise be bound and never called, which is the same as not bound.
(defvar brush-mirrored rl/Texture2D)
(defvar brush-mirrored-ok bool)
;; A missing file is not a crash and not silence: LoadTexture hands back a
;; texture with an id of 0, every draw with it is a no-op, and the program
;; looks like it has a drawing bug. So it is asked and said once, here.
(defn load-brush []
(set brush (rl/load-texture "brush.png"))
(set brush-ok (rl/texture-valid? brush))
(unless brush-ok
(print-line "sand: cannot load brush.png — drawing the cursor is off"))
;; The other route to a texture: the file into RAM, changed there, and only
;; then uploaded. An Image that failed to load has a null buffer and
;; unloading it is still safe, so there is one unload and not two.
(let [sheet (rl/load-image "brush.png")]
(when (rl/image-valid? sheet)
(rl/image-flip-horizontal (addr sheet))
(set brush-mirrored (rl/load-texture-from-image sheet)))
(rl/unload-image sheet))
(set brush-mirrored-ok (rl/texture-valid? brush-mirrored)))
;; Four draws, one per shape the call comes in, because none of them can be in
;; the acceptance table. The cursor picks one frame out of the sheet and so
;; needs a source rectangle; the three badges in the corner are the whole
;; sheet, at integer coordinates, at a Vector2 tinted with the current sand
;; colour, and scaled up — draw-texture, draw-texture-v and draw-texture-ex.
(defn draw-brush []
(when brush-ok
(let [m (rl/get-mouse-position)
frame (f32 (if (rl/mouse-button-down? :left) 8.0 0.0))]
(rl/draw-texture-rec brush
(rl/Rectangle {:x frame :y 0.0 :width 8.0 :height 8.0})
(rl/Vector2 {:x (- (.x m) 4.0) :y (- (.y m) 4.0)})
rl/white)
(rl/draw-texture brush 20 50 rl/white)
(rl/draw-texture-v brush (rl/Vector2 {:x 44.0 :y 50.0})
(rl/get-color (nth sim/colors sim/current-color)))
(rl/draw-texture-ex brush (rl/Vector2 {:x 72.0 :y 46.0}) 0.0 2.0 rl/white)))
;; The mirrored one beside them, scaled up so the flip is visible rather
;; than eight pixels wide. If the two badges look the same, either the flip
;; did nothing or the upload took the unedited buffer.
(when brush-mirrored-ok
(rl/draw-texture-ex brush-mirrored (rl/Vector2 {:x 110.0 :y 46.0})
0.0 2.0 rl/white)))
;; ── The view ────────────────────────────────────────────────────────
;;
;; begin-mode-2d and end-mode-2d were bound along with Camera2D and then
;; called by nothing at all, which is the same as not having bound them. They
;; are load-bearing here now: the grid is drawn through this camera, the arrow
;; keys pan it, comma and period zoom, and `paint` has to undo the transform
;; with get-screen-to-world-2d or the sand lands somewhere other than the
;; cursor. That last part is what makes this a check rather than decoration —
;; a camera plumbed in wrongly shows up as grains appearing in the wrong
;; place, at once, while zoomed.
(defvar view rl/Camera2D)
;; A fresh (Camera2D {}) has a zoom of 0, which is singular: both conversions
;; hand back NaN and nothing draws. 1.0 is the identity.
(defn reset-view []
(set view (rl/Camera2D {:offset (rl/Vector2 {:x 0.0 :y 0.0})
:target (rl/Vector2 {:x 0.0 :y 0.0})
:rotation 0.0
:zoom 1.0})))
(defn set-view [target-x f32 target-y f32 zoom f32]
(set view (rl/Camera2D {:offset (.offset view)
:target (rl/Vector2 {:x target-x :y target-y})
:rotation (.rotation view)
:zoom zoom})))
;; Panning is world units per second and zooming is a factor per second, so
;; neither changes with the frame rate. That is the whole of what
;; get-frame-time is for, and a loop that assumed it hit its target fps would
;; be a loop that moves differently on a slower machine.
(defn move-view []
(let [dt (rl/get-frame-time)
pan (* (f32 600.0) dt)
tx (.x (.target view))
ty (.y (.target view))
zoom (.zoom view)]
(when (rl/key-down? :left) (set tx (- tx pan)))
(when (rl/key-down? :right) (set tx (+ tx pan)))
(when (rl/key-down? :up) (set ty (- ty pan)))
(when (rl/key-down? :down) (set ty (+ ty pan)))
(when (rl/key-down? :comma) (set zoom (- zoom (* zoom dt))))
(when (rl/key-down? :period) (set zoom (+ zoom (* zoom dt))))
;; Clamped away from 0 for the reason above, and away from the far end
;; because a cell is 5 pixels and there is no point past a screenful of
;; one of them.
(set-view tx ty (min (f32 8.0) (max (f32 0.125) zoom)))
(when (rl/key-pressed? :zero) (reset-view))))
;; Locals are assignable places (spec-memory.md); parameters are not.
;;
;; The mouse is in screen pixels and the grid is in world cells, and with a
;; camera in the way those stopped being the same thing — so this is the one
;; place get-screen-to-world-2d is not a test case but a requirement.
(defn paint []
(let [m (rl/get-screen-to-world-2d (rl/get-mouse-position) view)
row (/ (i32 (.y m)) sim/cell-size)
col (/ (i32 (.x m)) sim/cell-size)]
(sim/paint-at row col)))
;; Every cross-function call in a dev build routes through an indirection cell,
;; so redefining this from the REPL reaches the running loop on the next frame.
;; No `varfn` (Janet), no `let update = ref` (OCaml), no var-routing (jank).
;; Release builds compile the same source to direct calls.
;;
;; A cell holds an (Fn ...) — a plain function pointer, no captured environment;
;; this one is (Fn [] Unit), `settle`'s is (Fn [i32 i32] Unit). Redefining
;; `settle` while `game-update` is mid-frame is safe
;; because old code is never unloaded; changing its SIGNATURE is not, and the
;; reload rejects it. See plan.org "What redefinition cannot do".
(defn game-update []
(when (rl/key-pressed? :r) (sim/clear-grid))
(move-view)
(when (rl/mouse-button-down? :left) (paint))
(when (rl/mouse-button-released? :left) (sim/next-color))
(sim/step))
(defn draw-grid []
(dotimes [row sim/rows]
(dotimes [col sim/cols]
(let [c (at sim/grid row col)]
(unless (= 0 c)
(rl/draw-rectangle (i32 (* col sim/cell-size))
(i32 (* row sim/cell-size))
sim/cell-size sim/cell-size
(rl/get-color c)))))))
;; Drawn inside the camera, in world units, so every one of these moves and
;; scales with the grid. That is the point: a shape binding that is subtly
;; wrong is easiest to see when it is supposed to sit exactly on the cursor
;; and does not.
(defn draw-world-cursor []
(let [p (rl/get-screen-to-world-2d (rl/get-mouse-position) view)
tint (rl/get-color (nth sim/colors sim/current-color))
x (.x p)
y (.y p)
r (f32 (* sim/brush-size sim/cell-size))]
;; The brush's actual reach, as a ring, plus a thinner circle outside it.
(rl/draw-ring p (* r (f32 0.9)) r (f32 0.0) (f32 360.0) 48 tint)
(rl/draw-circle-lines-v p (+ r (f32 6.0)) tint)
;; A crosshair: two thin lines and one thick one, which is three separate
;; raylib calls with three different shapes of argument.
(rl/draw-line-v (rl/Vector2 {:x (- x r) :y y})
(rl/Vector2 {:x (+ x r) :y y}) tint)
(rl/draw-line-v (rl/Vector2 {:x x :y (- y r)})
(rl/Vector2 {:x x :y (+ y r)}) tint)
(rl/draw-line-ex (rl/Vector2 {:x (- x (f32 4.0)) :y y})
(rl/Vector2 {:x (+ x (f32 4.0)) :y y}) (f32 3.0) rl/white)
;; The exact world point: a filled dot, and one pixel of white on top of
;; it. Both are the Vector2 forms, so they land where the ring's centre
;; is and not somewhere an integer cast put them.
(rl/draw-circle-v p (f32 5.0) tint)
(rl/draw-pixel-v p rl/white)
;; A pointer above the cursor. Counter-clockwise, because raylib culls the
;; other winding and draws nothing — which looks exactly like a broken
;; binding and is why the outline is drawn over it as a control.
(let [tip (rl/Vector2 {:x x :y (- y (+ r (f32 26.0)))})
left (rl/Vector2 {:x (- x (f32 12.0)) :y (- y (+ r (f32 6.0)))})
rght (rl/Vector2 {:x (+ x (f32 12.0)) :y (- y (+ r (f32 6.0)))})]
(rl/draw-triangle tip left rght tint)
(rl/draw-triangle-lines tip left rght rl/white))
;; And the world's own edge, so panning has something to pan against.
(rl/draw-rectangle-lines-ex
(rl/Rectangle {:x 0.0 :y 0.0
:width (f32 sim/screen-width)
:height (f32 sim/screen-height)})
(f32 2.0) (rl/get-color 0x303030FF))))
;; Drawn outside the camera, in screen pixels, so it stays put while the world
;; moves underneath it. Nothing here can be asserted — it needs a GL context —
;; so it is built to be *looked* at: every shape binding appears once, and each
;; one is asymmetric enough that a wrapper with its arguments crossed is
;; visible rather than merely different.
(defn draw-hud []
(let [title "SAND"
keys "arrows pan , . zoom 0 reset r clear"
;; measure-text is what sizes the panel, so the box fits the string
;; rather than a number somebody guessed. Headless it answers 0 for
;; everything, which is why it is not in the acceptance table.
w (max (rl/measure-text title 30) (rl/measure-text keys 20))
h 72
x 24
y (- (rl/get-screen-height) (+ h 24))
panel (rl/Rectangle {:x (f32 (- x 12)) :y (f32 (- y 12))
:width (f32 (+ w 24)) :height (f32 (+ h 24))})]
(rl/draw-rectangle-rounded panel (f32 0.2) 8 (rl/get-color 0x101018E0))
;; Both outline forms, one inside the other: the plain one has no
;; thickness in raylib 5.5 and the -ex one is where thickness went.
(rl/draw-rectangle-rounded-lines panel (f32 0.2) 8 (rl/get-color 0x404060FF))
(rl/draw-rectangle-rounded-lines-ex panel (f32 0.2) 8 (f32 2.0)
(rl/get-color 0x6060A0FF))
(rl/draw-text title x y 30 rl/white)
(rl/draw-text keys x (+ y 40) 20 (rl/get-color 0xA0A0B0FF))
;; The palette, along the bottom right. The selected colour is the one
;; with a ring round it, so `current-color` is readable off the screen.
(let [sw (- (rl/get-screen-width) 40)
sh (- (rl/get-screen-height) 40)]
(dotimes [i (len sim/colors)]
(let [cx (- sw (* (- (len sim/colors) (+ i 1)) 46))
c (rl/get-color (nth sim/colors i))]
(rl/draw-circle cx sh (f32 16.0) c)
(when (= i sim/current-color)
(rl/draw-circle-lines cx sh (f32 22.0) rl/white))))
;; A zoom read-out with no number in it, because there is no string
;; formatting yet: the bar's length is the zoom. draw-rectangle-rec,
;; draw-rectangle-v and draw-rectangle-lines are the three remaining
;; rectangle shapes, so all three are here rather than invented
;; elsewhere.
(let [bx (f32 (- sw 220))
by (f32 (- sh 60))
fill (* (f32 200.0) (min (f32 1.0) (/ (.zoom view) (f32 8.0))))]
(rl/draw-rectangle-rec (rl/Rectangle {:x bx :y by :width (f32 200.0)
:height (f32 10.0)})
(rl/get-color 0x202028FF))
(rl/draw-rectangle-v (rl/Vector2 {:x bx :y by})
(rl/Vector2 {:x fill :y (f32 10.0)})
(rl/get-color 0x6060A0FF))
(rl/draw-rectangle-lines (- sw 220) (- sh 60) 200 10
(rl/get-color 0x8080C0FF))
;; The tick at the identity zoom, and a single pixel marking its left
;; end — the integer forms of the line and pixel calls.
(rl/draw-line (+ (- sw 220) 25) (- sh 66) (+ (- sw 220) 25) (- sh 46)
rl/white)
(rl/draw-pixel (- sw 220) (- sh 66) rl/white))
;; An ellipse, deliberately wider than it is tall so that exchanging its
;; two radii would be obvious, and a ring whose sweep is driven by
;; get-time so that something on screen proves the clock is running.
(let [ex (- sw 320)
ey (- sh 20)
spin (f32 (* 60.0 (rl/get-time)))]
(rl/draw-ellipse ex ey (f32 26.0) (f32 12.0) (rl/get-color 0x303040FF))
(rl/draw-ellipse-lines ex ey (f32 26.0) (f32 12.0)
(rl/get-color 0x8080C0FF))
(rl/draw-ring-lines (rl/Vector2 {:x (f32 ex) :y (f32 ey)})
(f32 30.0) (f32 34.0) spin (+ spin (f32 270.0)) 32
rl/white)))))
(defn game-draw []
(rl/clear-background rl/black)
;; Everything between these two is in world space and moves with the camera.
(rl/begin-mode-2d view)
(draw-grid)
(draw-world-cursor)
(rl/end-mode-2d)
;; And everything after it is in screen pixels again.
(draw-brush)
(draw-hud)
(rl/draw-fps 20 20))
(defn main []
(rl/set-trace-log-level :warning)
(rl/init-window sim/screen-width sim/screen-height "SAND")
(defer (rl/close-window))
(rl/set-target-fps 120)
;; Before anything draws: a zero zoom is singular and nothing would appear.
(reset-view)
;; After the window, never before: LoadTexture uploads to the GPU and there
;; is no GPU to upload to until InitWindow has made a context.
(load-brush)
(defer (rl/unload-texture brush))
(defer (rl/unload-texture brush-mirrored))
;; The dev agent listens on a socket for redefinitions and hands them over;
;; (agent/poll) below is where they are installed. Building without --dev is
;; fine — nothing has cells to install into, so a module is refused on the
;; listener thread and the loop never notices.
(agent/start "/tmp/flan-sand.sock")
;; Bare (defn main []) — argv and the i32 status are both optional.
;; Nothing in this loop allocates, so context/temp is never even touched.
(until (rl/window-should-close?)
;; The frame boundary, and the only place a redefinition becomes visible:
;; nothing that could be redefined is on the stack here.
(agent/poll)
(game-update)
(rl/begin-drawing)
(game-draw)
(rl/end-drawing)))