The struct-by-value pointer trick (Camera2D / Camera3D)

raylib's cameras are structs passed by value: BeginMode2D(Camera2D) takes 24 bytes, BeginMode3D(Camera3D) takes 44 bytes. Chez foreign-procedure (what jolt.ffi/defcfn lowers to) has no by-value-aggregate calling convention. Unlike Color (see color-by-value.md) these are far too big to fake as a register-width int. But on AArch64 there is still a way โ€” and it comes straight out of the ABI.

The ABI fact

typedef struct Camera2D {   // 24 bytes
    Vector2 offset;         //  8  (float x, y)
    Vector2 target;         //  8  (float x, y)
    float   rotation;       //  4
    float   zoom;           //  4
} Camera2D;

void BeginMode2D(Camera2D camera);

On the AArch64 (Apple silicon) procedure-call standard, a composite larger than 16 bytes is passed indirectly: the caller allocates the struct somewhere, and what actually goes in the argument register is a pointer to it. So at the machine level, BeginMode2D(Camera2D) and BeginMode2D(Camera2D*) are the same call โ€” the callee receives a pointer either way.

That means a Jolt binding can declare the parameter as [:pointer], build the 24 bytes in native memory itself, and pass the pointer. The C side never knows the difference.

The Jolt side

src/net/b12n/raylib_jlt/raylib.clj binds BeginMode2D as a pointer taker and builds the struct with ffi/alloc + six little-endian ffi/write :floats:

(ffi/defcfn ^:private begin-mode-2d-ptr "BeginMode2D" [:pointer] :void)
(ffi/defcfn end-mode-2d "EndMode2D" [] :void)

(defn with-camera-2d
  [{:keys [offset-x offset-y target-x target-y rotation zoom]
    :or {offset-x 0 offset-y 0 target-x 0 target-y 0 rotation 0 zoom 1.0}} f]
  (let [p (ffi/alloc 24)]
    (try
      (ffi/write p :float 0  (double offset-x))
      (ffi/write p :float 4  (double offset-y))
      (ffi/write p :float 8  (double target-x))
      (ffi/write p :float 12 (double target-y))
      (ffi/write p :float 16 (double rotation))
      (ffi/write p :float 20 (double zoom))
      (begin-mode-2d-ptr p)
      (f)
      (end-mode-2d)
      (finally (ffi/free p)))))

The struct layout is the whole game: the field byte-offsets must match the C struct exactly (0, 4, 8, 12 for the four Vector2 floats, then 16 = rotation, 20 = zoom), and the buffer size must equal sizeof(Camera2D) = 24. Get either wrong and you read garbage or corrupt the stack.

flowchart LR
  j["Jolt: (ffi/alloc 24)<br/>write 6 floats"]
  j -->|"[:pointer] p"| b["BeginMode2D(Camera2D*)<br/>(= by-value on AArch64)"]
  b --> f["(f) draws in camera space"]
  f --> e["EndMode2D ยท (ffi/free p)"]

Camera3D is the same recipe, bigger

Camera3D is 44 bytes โ€” three Vector3 (position, target, up) + a float fovy + an int projection. with-camera-3d allocates 44 and writes nine floats then one int:

(let [p (ffi/alloc 44)]
  ;; โ€ฆ nine (ffi/write p :float โ€ฆ) at offsets 0..36 โ€ฆ
  (ffi/write p :float 36 (double fovy))
  (ffi/write p :int   40 (int projection))   ; 0 = perspective
  (begin-mode-3d-ptr p) (f) (end-mode-3d))

Both wrappers use try/finally so the native buffer is freed even if f throws โ€” the same discipline the sibling project applies to its node buffers.

The x86-64 caveat โ€” this is not portable as written

The trick works because AArch64 passes >16-byte structs indirectly. On the x86-64 System V ABI, those 24 bytes are classified and passed on the stack (in pieces), which a [:pointer] binding does not do โ€” it would put a pointer where the callee expects 24 bytes of struct data, and the call reads garbage (typically an "invalid memory reference" crash).

So camera2d/camera-3d are AArch64-only as written. The portable alternative is to skip BeginMode2D entirely and apply the same transform with rlgl's scalar matrix ops (rlPushMatrix/rlTranslatef/rlRotatef/rlScalef) โ€” which is exactly what BeginMode2D does internally. See rlgl-immediate-mode.md for the matrix stack. The source flags this inline:

;; NOTE: this is AArch64-specific โ€” on the x86-64 SysV ABI the 24 bytes are passed
;; on the stack, which a [:pointer] binding does NOT do (see README). For a portable
;; alternative, apply the same transform with the scalar rlgl matrix ops instead.

By-value returns are a different, harder problem

This trick fakes a by-value struct argument. It does nothing for a function that returns a struct by value โ€” on AArch64 that uses the x8 sret register, which Chez doesn't expose. raylib's hot path never needs a by-value struct return, so this repo never hits it. Its Jolt sibling b12n-tsj (not yet public) is not so lucky: tree-sitter's ts_tree_root_node returns a 32-byte TSNode by value and ts_node_type takes one, so it can't fake either direction with a pointer binding and must ship a C shim. That contrast is the reason both projects exist side by side.

Why this generalizes

For any Chez/Jolt FFI on AArch64 that needs to pass a >16-byte struct by value: allocate sizeof(T) with ffi/alloc, write each field at its C offset, bind the function as [:pointer], and free in finally. Confirm sizeof and every offset against the C header. Do not assume it ports to x86-64 โ€” reach for rlgl matrix ops (or a real shim) there.

See also

  • color-by-value.md โ€” the small-struct case that fits in a register and needs none of this.
  • rlgl-immediate-mode.md โ€” the portable matrix-stack alternative, and the fallback for by-value float structs (Vector2/Vector3).
  • b12n-tsj (not yet public) โ€” the sibling case where the pointer trick isn't enough (by-value returns) and you must write a C shim.