(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 (.id t)) (println "") (print (.width t)) (println "") (print (.height t)) (println "") (print (.mipmaps t)) (println "") (print (.format t)) (println "")) (defn show-rect [r rl/Rectangle] (print (.x r)) (println "") (print (.y r)) (println "") (print (.width r)) (println "") (print (.height r)) (println "")) ;; ── 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 name) (print " ") (println (if b "yes" "no"))) (defn show-v [v rl/Vector2] (print (.x v)) (println "") (print (.y v)) (println "")) ;; 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 name) (println (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 (.r c)) (println "") (print (.g c)) (println "") (print (.b c)) (println "") (print (.a c)) (println "")) ;; 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 (println "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 (println "no crossing")) ;; An enum reaching a declare-c parameter through (Enum n): the axis is a ;; loop variable, which is what nothing could express before. No pad is ;; attached, so raylib answers its own resting value per axis — 0.0 for the ;; four stick axes and -1.0 for the two triggers, which rest at the negative ;; end. That split is what makes this pin worth having: it shows six ;; distinct i32s arriving as six distinct GamepadAxis members rather than ;; one constant answered six times. (print "axes") (dotimes [i 6] (print " ") (print (rl/get-gamepad-axis-movement 0 (rl/GamepadAxis i)))) ;; And an axis that is no declared member. raylib bounds-checks it itself and ;; answers 0.0; the conversion does not refuse it, for the same reason the ;; printer shows an out-of-range enum as its number. (print " past-end ") (print (rl/get-gamepad-axis-movement 0 (rl/GamepadAxis 9))) (println "") 0)