flan/test/programs/raylib-ffi.flan
Joseph Ferano 386d9e0372 Call every collision binding at least once
Six of them were bound, linked, and had never been called by anything. That is
the state a wrong argument order survives indefinitely: the link succeeds, the
program runs, and the answer is nonsense that nobody has looked at. An audit for
wrappers with no caller is worth doing after any binding lane.

All six turned out to be correct, which is worth recording either way - the
point of the audit is not that it finds bugs but that it converts "probably
fine" into "called, and the answer checked".

Each has a case that must come out the other way, because a predicate that
always said yes would pass a single one.

The one that earns the most is the polygon, the only binding here that crosses a
slice, so the only place ptr+len has to arrive as raylib's pointer and count.
Everything else about it would pass with a hardcoded count or with the pointer
alone; the same point against the same array with three corners instead of four
is what pins the length. Verified by hardcoding the count in the shim and
watching it go the wrong way.
2026-09-11 19:20:13 +07:00

274 lines
15 KiB
Plaintext

(import rl "vendor:raylib")
;; The raylib boundary, headless. GetColor, the shapes texture and rectangle
;; intersection all need no window, so the whole crossing — a struct out of C
;; through an out-pointer, a struct into C through a pointer, a keyword
;; resolved against an enum — is exercised without a display.
;;
;; What is being checked is that a struct's FIELDS mean the same thing on both
;; sides. Note what does not check that: handing raylib a struct and reading it
;; back, because storing and returning is symmetric and a permuted layout
;; survives it unchanged. Every case below is asymmetric — raylib does
;; something to the fields that depends on which is which.
(defn show-texture [t rl/Texture2D]
(print-i64 (i64 (.id t))) (newline)
(print-i64 (i64 (.width t))) (newline)
(print-i64 (i64 (.height t))) (newline)
(print-i64 (i64 (.mipmaps t))) (newline)
(print-i64 (i64 (.format t))) (newline))
(defn show-rect [r rl/Rectangle]
(print-f64 (f64 (.x r))) (newline)
(print-f64 (f64 (.y r))) (newline)
(print-f64 (f64 (.width r))) (newline)
(print-f64 (f64 (.height r))) (newline))
;; ── Camera2D ────────────────────────────────────────────────────────
;;
;; The two conversions are pure arithmetic and need no window, which makes
;; them the strongest headless material in the package: each one reads every
;; field of a Camera2D and every field of two Vector2s.
;;
;; They are asserted in both directions separately and never as a round trip.
;; world->screen->world is the store-and-return trap wearing a different hat:
;; the inverse cancels a permuted layout exactly, so it passes for any order.
;;
;; The camera below is chosen so that no field is silently unpinned — offset
;; and target differ, zoom is 2.0 and not the identity 1.0, and every
;; component is a distinct dyadic value that prints exactly.
(defconst cam (rl/Camera2D {:offset (rl/Vector2 {:x 100.0 :y 50.0})
:target (rl/Vector2 {:x 8.0 :y 4.0})
:rotation 0.0
:zoom 2.0}))
;; Absolute values, not a round trip: screen (140,90) is world (28,24) because
;; ((140-100)/2)+8 = 28 and ((90-50)/2)+4 = 24. Swap offset and target in the
;; defstruct and this reads (143,-16); swap rotation and zoom and the zoom
;; becomes 0, the transform is singular, and both come back NaN.
(defn show-bool [name string b bool]
(print-str name) (print-str " ")
(print-line (if b "yes" "no")))
(defn show-v [v rl/Vector2]
(print-f64 (f64 (.x v))) (newline)
(print-f64 (f64 (.y v))) (newline))
;; What no geometric call can pin on its own is Vector2's own two fields:
;; exchange x and y everywhere and every component-wise formula is simply
;; mirrored, so the answer comes back mirrored too and compares equal. A
;; *rotated* camera is the exception — it mixes x into y — so the case below
;; is the one thing in this file that fixes which float is which.
;;
;; It cannot be compared as text: 90 degrees goes through sinf and cosf and
;; the answer is 27.9999981, not 28, and the table compares stdout byte for
;; byte at -O0 and -O2. So the comparison happens here, with a tolerance, and
;; what is printed is the verdict. A wrong-but-close value passing is not a
;; risk worth naming: a permuted layout is out by whole units. Swapping x and
;; y in Vector2 makes this print "rotated bad" — the same camera then reads
;; back as (-12,24).
(defn near? [a f32 b f32] bool
(let [d (- a b)]
(< (if (< d 0.0) (- 0.0 d) d) 0.0001)))
(defn show-near [name string v rl/Vector2 x f32 y f32]
(print-str name)
(print-line (if (and (near? (.x v) x) (near? (.y v) y)) " ok" " bad")))
(defn main [] i32
(rl/set-trace-log-level :warning)
;; A Color is four bytes in RGBA order, so 0x11223344 is 17 34 51 68 and not
;; the little-endian reading of the packed integer. An identity would pass a
;; weaker test than this one.
(let [c (rl/get-color 0x11223344)]
(print-i64 (i64 (.r c))) (newline)
(print-i64 (i64 (.g c))) (newline)
(print-i64 (i64 (.b c))) (newline)
(print-i64 (i64 (.a c))) (newline))
;; Rectangle, pinned completely. The intersection of (0,0,10,4) and
;; (6,1,10,10) is (6,1,4,3) — four different numbers, each derived from a
;; different pair of fields, so swapping any two fields changes the answer.
(show-rect (rl/get-collision-rec (rl/Rectangle {:x 0.0 :y 0.0 :width 10.0 :height 4.0})
(rl/Rectangle {:x 6.0 :y 1.0 :width 10.0 :height 10.0})))
;; Texture2D, as far as a machine with no GPU can go. raylib keeps the
;; shapes texture without touching GL, and substitutes a default when
;; `texture.id`, `source.width` or `source.height` is zero — that guard is
;; the only asymmetry a headless test gets.
(let [rect (rl/Rectangle {:x 3.5 :y 7.25 :width 11.5 :height 13.75})]
;; (A) Valid, five distinct values: they come back, so the struct crosses
;; intact in both directions and raylib stored it rather than defaulting.
(rl/set-shapes-texture (rl/Texture2D {:id 7 :width 13 :height 17 :mipmaps 2 :format 4}) rect)
(show-texture (rl/get-shapes-texture))
(show-rect (rl/get-shapes-texture-rectangle))
;; (B) id zero, everything else positive: the default 1 1 1 1 7 comes
;; back. The 7 is the only distinct field in it, so this pins `format` as
;; the last field, and the substitution happening at all pins `id` as the
;; field the guard reads.
(rl/set-shapes-texture (rl/Texture2D {:id 0 :width 13 :height 17 :mipmaps 2 :format 4}) rect)
(show-texture (rl/get-shapes-texture))
;; (C) width zero, id positive: still stored, because the guard does not
;; look at the texture's width. Without this case, (B) would pass just as
;; well with `id` and `width` swapped — the zero would land in the guarded
;; slot either way.
;;
;; That is the limit of what is checkable here: nothing raylib computes
;; without a GL context reads width, height or mipmaps, so their order
;; among themselves is not pinned by this test. A swap there shows up as a
;; visibly wrong sprite in the interactive run, and nowhere else.
(rl/set-shapes-texture (rl/Texture2D {:id 7 :width 0 :height 17 :mipmaps 2 :format 4}) rect)
(show-texture (rl/get-shapes-texture)))
;; Camera2D, each direction on its own. See the note above show-v for why
;; this is not a round trip.
(show-v (rl/get-screen-to-world-2d (rl/Vector2 {:x 140.0 :y 90.0}) cam))
(show-v (rl/get-world-to-screen-2d (rl/Vector2 {:x 28.0 :y 24.0}) cam))
;; And the rotated camera, which is what pins Vector2's own two fields.
(let [spun (rl/Camera2D {:offset (rl/Vector2 {:x 100.0 :y 50.0})
:target (rl/Vector2 {:x 8.0 :y 4.0})
:rotation 90.0
:zoom 2.0})]
(show-near "rotated screen-to-world"
(rl/get-screen-to-world-2d (rl/Vector2 {:x 140.0 :y 90.0}) spun)
28.0 -16.0)
(show-near "rotated world-to-screen"
(rl/get-world-to-screen-2d (rl/Vector2 {:x 28.0 :y 24.0}) spun)
60.0 90.0))
;; ── Collision, which is the best material a headless test gets ──────
;;
;; Every one of these is pure: raylib computes an answer out of the fields,
;; so a wrong field order gives a wrong answer rather than the same struct
;; back. Each case below is paired with one that must come out the other
;; way, because a predicate that always said yes would pass a single case.
(let [r (rl/Rectangle {:x 0.0 :y 0.0 :width 10.0 :height 4.0})]
;; Inside on both axes, then outside on y only. Swap width and height and
;; both of these flip, which is what makes the pair worth more than either.
(show-bool "point in rect" (rl/collision-point-rec? (rl/Vector2 {:x 5.0 :y 3.0}) r))
(show-bool "point below rect" (rl/collision-point-rec? (rl/Vector2 {:x 5.0 :y 5.0}) r))
;; Overlapping by one unit, then clear of it. Pins x against width.
(show-bool "rects overlap"
(rl/collision-recs? r (rl/Rectangle {:x 9.0 :y 1.0 :width 10.0 :height 10.0})))
(show-bool "rects apart"
(rl/collision-recs? r (rl/Rectangle {:x 11.0 :y 1.0 :width 10.0 :height 10.0}))))
;; Centres five apart with radii summing to six, then seven apart. The radius
;; is a scalar beside two Vector2s, so this pins it against their fields.
(show-bool "circles touch"
(rl/collision-circles? (rl/Vector2 {:x 0.0 :y 0.0}) 3.0
(rl/Vector2 {:x 5.0 :y 0.0}) 3.0))
(show-bool "circles clear"
(rl/collision-circles? (rl/Vector2 {:x 0.0 :y 0.0}) 3.0
(rl/Vector2 {:x 7.0 :y 0.0}) 3.0))
;; The one that answers with a number rather than a yes: a horizontal segment
;; at y = 7 crossed by a vertical one at x = 3, so the answer is (3 7).
;;
;; Asymmetric rather than the two diagonals of a square meeting at (5 5),
;; which would give the same answer however the fields were ordered — but be
;; clear about what that buys, because it is less than it looks.
;;
;; **Nothing in this section pins Vector2's own two fields, and nothing
;; axis-aligned can.** Exchanging x and y is a reflection: it is applied to
;; the inputs on the way in and undone on the way out, so the printed answer
;; is identical. Verified by actually swapping the shim's typedef — every
;; collision case here still passes. Distances are worse still, being
;; unchanged by the reflection in the first place.
;;
;; The rotated camera above is what pins them, and it works precisely because
;; a 90-degree rotation is not axis-aligned, so the reflection does not
;; commute with it. That case is load-bearing; do not delete it because the
;; collision ones look like they cover the same ground.
;;
;; What this section *does* pin is Rectangle, completely — swapping width and
;; height turns three of the four predicates below the wrong way.
(match (rl/collision-lines (rl/Vector2 {:x 0.0 :y 7.0}) (rl/Vector2 {:x 10.0 :y 7.0})
(rl/Vector2 {:x 3.0 :y 0.0}) (rl/Vector2 {:x 3.0 :y 10.0}))
(Some p) (show-v p)
None (print-line "no crossing"))
;; The rest of the collision family, each with the case that must come out
;; the other way. Bound and linking is not the same as working: a wrapper
;; whose arguments are in the wrong order links perfectly and answers
;; nonsense, and until something calls it nothing says so.
(let [r (rl/Rectangle {:x 0.0 :y 0.0 :width 10.0 :height 4.0})]
;; Circle against rect: just touching at the right edge, then clear of it.
(show-bool "circle meets rect"
(rl/collision-circle-rec? (rl/Vector2 {:x 12.0 :y 2.0}) 3.0 r))
(show-bool "circle clears rect"
(rl/collision-circle-rec? (rl/Vector2 {:x 14.0 :y 2.0}) 3.0 r)))
;; Circle against a segment, which is the one that pins the radius against
;; the two endpoints rather than against a single centre.
(show-bool "circle meets line"
(rl/collision-circle-line? (rl/Vector2 {:x 5.0 :y 2.0}) 3.0
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 10.0 :y 0.0})))
(show-bool "circle clears line"
(rl/collision-circle-line? (rl/Vector2 {:x 5.0 :y 4.0}) 3.0
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 10.0 :y 0.0})))
(show-bool "point in circle"
(rl/collision-point-circle? (rl/Vector2 {:x 2.0 :y 0.0})
(rl/Vector2 {:x 0.0 :y 0.0}) 3.0))
(show-bool "point outside circle"
(rl/collision-point-circle? (rl/Vector2 {:x 4.0 :y 0.0})
(rl/Vector2 {:x 0.0 :y 0.0}) 3.0))
;; A right triangle with the square corner at the origin. The inside point is
;; inside for one vertex order and not the other, so this is one of the few
;; here that notices which vertex is which.
(show-bool "point in triangle"
(rl/collision-point-triangle? (rl/Vector2 {:x 1.0 :y 1.0})
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 8.0 :y 0.0})
(rl/Vector2 {:x 0.0 :y 6.0})))
(show-bool "point outside triangle"
(rl/collision-point-triangle? (rl/Vector2 {:x 7.0 :y 5.0})
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 8.0 :y 0.0})
(rl/Vector2 {:x 0.0 :y 6.0})))
;; On the segment, then beside it. The threshold is the last argument, so a
;; wrapper that lost it among the four coordinates answers with whatever was
;; in that register.
(show-bool "point on line"
(rl/collision-point-line? (rl/Vector2 {:x 5.0 :y 0.0})
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 10.0 :y 0.0}) 1))
(show-bool "point off line"
(rl/collision-point-line? (rl/Vector2 {:x 5.0 :y 4.0})
(rl/Vector2 {:x 0.0 :y 0.0})
(rl/Vector2 {:x 10.0 :y 0.0}) 1))
;; The only one that crosses a *slice*, so it is the only one where ptr+len
;; has to arrive as raylib's pointer-and-count. A wrong length reads past the
;; array or stops short, and either way the square stops being a square.
(let [square [(rl/Vector2 {:x 0.0 :y 0.0}) (rl/Vector2 {:x 8.0 :y 0.0})
(rl/Vector2 {:x 8.0 :y 8.0}) (rl/Vector2 {:x 0.0 :y 8.0})]]
(show-bool "point in poly"
(rl/collision-point-poly? (rl/Vector2 {:x 4.0 :y 4.0}) (slice square 0 4)))
(show-bool "point outside poly"
(rl/collision-point-poly? (rl/Vector2 {:x 12.0 :y 4.0}) (slice square 0 4)))
;; The same point against the same array, three corners instead of four:
;; inside the square, outside the triangle the first three make. This is
;; the case that proves the *length* crosses — everything above would pass
;; with a hardcoded count, or with the pointer alone.
(show-bool "in square, four corners"
(rl/collision-point-poly? (rl/Vector2 {:x 2.0 :y 6.0}) (slice square 0 4)))
(show-bool "out of triangle, three"
(rl/collision-point-poly? (rl/Vector2 {:x 2.0 :y 6.0}) (slice square 0 3))))
;; Parallel, so they never meet: None rather than a point nobody wrote.
(match (rl/collision-lines (rl/Vector2 {:x 0.0 :y 0.0}) (rl/Vector2 {:x 1.0 :y 2.0})
(rl/Vector2 {:x 5.0 :y 0.0}) (rl/Vector2 {:x 6.0 :y 2.0}))
(Some p) (show-v p)
None (print-line "no crossing"))
0)