feat(pkg): phase 15 - a package can carry a native library

native "<target>" "<path>" in a package's package.ludic; the compiler records the libraries of
every package a program imports and writes them into the IR (; ludic-native:), so ludicc -o,
ludic build, ludic test and ludic bundle all link one list. macOS: an rpath to the package and to
Contents/Frameworks, where ludic bundle copies and signs each library and drops the build
machine's rpath. Windows: the import library, the .dll copied beside the exe (--natives-out for
the bundle). tools/native/lib.sh builds from a pinned, checksummed source with clang on both
machines; ludic.nativeecho is the worked example; the shim rules are in packages/README.md.
Linked at build time rather than dlopen (docs/PACKAGES.md says why). Reseeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-26 23:35:45 +03:00
parent 5df0747642
commit 5d3a799e33
25 changed files with 54474 additions and 51707 deletions

View file

@ -52,6 +52,39 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.lab](ludic.lab/README.md) | the visual lab: a scene on a lit plate, fixed cameras, PNG shots and a contact sheet |
| [ludic.core](ludic.core/) | the canonical engine-ABI components |
| [ludic.prefs](ludic.prefs/) | a small `key=value` store for preferences and records |
| [ludic.nativeecho](ludic.nativeecho/README.md) | the worked example of a package carrying a native library: a C sum over a slice, callbacks drained as facts |
## Native libraries
A package may carry a C or C++ library (`native` lines in its `package.ludic`,
[docs/PACKAGES.md](../docs/PACKAGES.md#native-libraries---a-source-package-that-carries-a-cc-library-phase-15)).
The library is never bound directly: a thin C shim in the package's `native/shim/` is, and every
shim keeps the same shape so the Ludic side stays safe.
- **Only `int`, `long`, `float` and opaque handles cross.** A Ludic `float` is a C `float`, a
`long` an `int64_t`, a `pointer` a `void *`. No struct is passed by value, no C++ type, no
exception: the shim catches and turns it into a return code.
- **A handle is the library's pointer, kept in the package's state and never exported.** The
game names what the package hands it - an int id - and the package maps it to the handle.
- **Nothing calls back into Ludic.** No function pointer crosses the boundary. What a library
reports through a callback (a contact, a finished path, a mixed buffer) the shim records in a
buffer of its own, and the package drains it after the call returns (`ne_scan` then
`ne_drain`), pushing each record onto its facts queue. A record that did not fit is counted,
not dropped silently.
- **Bulk data crosses as a slice the package owns.** A `[]float` or `[]int` passed where the
shim takes a pointer is its data; the package makes it once and reuses it (L7), and passes its
length beside it.
- **Errors are return codes.** 0 is success and a negative number says what failed; nothing
aborts, nothing prints.
- **Threads a library starts stay inside C** (a job system, an audio device). The Ludic side is
called on the game's thread only.
- **The symbols are prefixed** with the package's short name (`ne_`, `jph_`), exported explicitly
(`__attribute__((visibility("default")))`, `__declspec(dllexport)`), and everything else is
hidden.
`native/build.sh` builds `lib/<target>/` from the pinned upstream source and the shim through
[tools/native/lib.sh](../tools/native/lib.sh); run it on the Mac and, in Git Bash, on the PC.
The package's tests run the real library - there is no fake for it.
## The builtin controllers

View file

@ -0,0 +1,26 @@
# ludic.nativeecho
The worked example of a package that carries a native library (phase 15). It exists to be copied
and to be tested: nothing in a game needs it.
```ludic
import "ludic.nativeecho"
```
- `package.ludic` names the library per target (`native "macos-arm64" "lib/macos-arm64/libnativeecho.dylib"`).
- `native/shim/nativeecho.c` is the whole library: a sum over a float slice, a scan that
"calls back" once per value over a threshold, and the drain the package empties those records
through.
- `native/build.sh` builds `lib/<target>/` through `tools/native/lib.sh` (no upstream to fetch).
- `native.ludic` declares the symbols; `echo.ludic` is the state, the verbs and the facts.
| | |
| --- | --- |
| `EchoState` | the slices that cross to C (made once), and the facts queue |
| `echo_put(st, x) -> bool`, `echo_clear(st)` | fill the slice |
| `echo_sum(st) -> float` | the sum, in C |
| `echo_scan(st, over) -> int` | C records each value over `over`; they arrive on `echo_facts(st)` as `EchoFact { index, value }` |
| `echo_lost() -> int`, `echo_twice(x: long) -> long` | the lost-record count, and a 64-bit round trip |
The rules it keeps are the shim rules in [packages/README.md](../README.md#native-libraries).
`ludic test packages/ludic.nativeecho` runs it against the real library.

View file

@ -0,0 +1,69 @@
# echo.ludic - the state, its verbs and its facts. The slices crossing to C are made once and
# reused (L7), and what C recorded is drained into the queue after each call returns.
export const ECHO_CAP: int = 64
export property EchoFact {
index: int = 0 # which element went over
value: float = 0.0
}
export state EchoState {
ec_xs: []float = null
ec_n: int = 0
ec_kinds: []int = null
ec_values: []float = null
ec_facts: Queue<EchoFact> = null
}
function ec_ready(echo_st: mut EchoState) -> void {
if echo_st.ec_xs == null { echo_st.ec_xs = floats(ECHO_CAP) }
if echo_st.ec_kinds == null { echo_st.ec_kinds = words(ECHO_CAP) }
if echo_st.ec_values == null { echo_st.ec_values = floats(ECHO_CAP) }
}
# what went over, oldest first
export function echo_facts(echo_st: mut EchoState) -> Queue<EchoFact> {
if echo_st.ec_facts == null { echo_st.ec_facts = queue_new("nativeecho.facts") }
return echo_st.ec_facts
}
# put a value in the next slot; false when the slice is full
export function echo_put(echo_st: mut EchoState, x: float) -> bool {
ec_ready(echo_st)
if echo_st.ec_n >= ECHO_CAP { return false }
echo_st.ec_xs[echo_st.ec_n] = x
echo_st.ec_n += 1
return true
}
export function echo_clear(echo_st: mut EchoState) -> void { echo_st.ec_n = 0 }
# the sum of what was put, worked out in C
export function echo_sum(echo_st: mut EchoState) -> float {
ec_ready(echo_st)
return ne_sum(echo_st.ec_xs, echo_st.ec_n)
}
# C walks the values and records each one over `over`; the records come back as facts
export function echo_scan(echo_st: mut EchoState, over: float) -> int {
ec_ready(echo_st)
let hits = ne_scan(echo_st.ec_xs, echo_st.ec_n, over)
ec_drain(echo_st)
return hits
}
function ec_drain(echo_st: mut EchoState) -> void {
let n = ne_drain(echo_st.ec_kinds, echo_st.ec_values, ECHO_CAP)
for i in 0 .. n {
let f = new EchoFact
f.index = echo_st.ec_kinds[i]
f.value = echo_st.ec_values[i]
q_push(echo_facts(echo_st), f)
}
}
# how many records C had to drop because nobody drained them
export function echo_lost() -> int { return ne_lost_count() }
# a 64-bit value there and back
export function echo_twice(x: long) -> long { return ne_twice(x) }

View file

@ -0,0 +1,7 @@
# ludic.nativeecho - the worked example of a package carrying a native library (phase 15): a C
# sum over a slice it owns, and a C scan whose "callbacks" come back as facts on its queue
module ludic_nativeecho uses ludic_base
numbers float
import "ludic.base"
import "native.ludic"
import "echo.ludic"

BIN
packages/ludic.nativeecho/lib/macos-arm64/libnativeecho.dylib (Stored with Git LFS) Executable file

Binary file not shown.

View file

@ -0,0 +1,7 @@
# native.ludic - the shim's symbols (native/shim/nativeecho.c), in the types that cross:
# int, long, float and a slice's data as a pointer. Nothing here is exported.
extern function ne_sum(xs: pointer, n: int) -> float = "ne_sum"
extern function ne_scan(xs: pointer, n: int, over: float) -> int = "ne_scan"
extern function ne_drain(kinds: pointer, values: pointer, cap: int) -> int = "ne_drain"
extern function ne_lost_count() -> int = "ne_lost_count"
extern function ne_twice(x: long) -> long = "ne_twice"

View file

@ -0,0 +1,10 @@
#!/bin/sh
# builds lib/<target>/ for ludic.nativeecho: no upstream library, only the shim
# (tools/native/lib.sh; a package that wraps a library adds native_fetch first - see ludic.physics)
set -eu
PKG="$(cd "$(dirname "$0")/.." && pwd)"
. "$PKG/../../tools/native/lib.sh"
OBJ="$PKG/build/native"
mkdir -p "$OBJ"
"$(native_cc)" $(native_cflags) -c "$PKG/native/shim/nativeecho.c" -o "$OBJ/nativeecho.o"
native_link "$PKG" nativeecho "$OBJ/nativeecho.o"

View file

@ -0,0 +1,59 @@
/* nativeecho - the shape every shim in packages/ has (packages/README.md, "Native libraries"):
int, long, float and opaque handles cross; no struct by value, no callback into Ludic. What a
library would report through a callback is recorded here and drained after the call returns. */
#include <stdint.h>
#if defined(_WIN32)
#define NE_API __declspec(dllexport)
#else
#define NE_API __attribute__((visibility("default")))
#endif
#define NE_CAP 64
static int ne_kinds[NE_CAP];
static float ne_values[NE_CAP];
static int ne_count = 0;
static int ne_lost = 0;
/* the sum of n floats the package owns */
NE_API float ne_sum(const float *xs, int n) {
float s = 0.0f;
for (int i = 0; i < n; i++) s += xs[i];
return s;
}
/* what a callback would have reported: kept until drained, counted as lost past the cap */
NE_API int ne_record(int kind, float value) {
if (ne_count >= NE_CAP) { ne_lost++; return -1; }
ne_kinds[ne_count] = kind;
ne_values[ne_count] = value;
ne_count++;
return 0;
}
/* a library's work that "calls back" once per element over a threshold */
NE_API int ne_scan(const float *xs, int n, float over) {
int hits = 0;
for (int i = 0; i < n; i++) {
if (xs[i] > over) { ne_record(i, xs[i]); hits++; }
}
return hits;
}
/* copy up to cap pending records into two slices the package owns; returns how many */
NE_API int ne_drain(int *kinds, float *values, int cap) {
int n = ne_count < cap ? ne_count : cap;
for (int i = 0; i < n; i++) { kinds[i] = ne_kinds[i]; values[i] = ne_values[i]; }
for (int i = n; i < ne_count; i++) {
ne_kinds[i - n] = ne_kinds[i];
ne_values[i - n] = ne_values[i];
}
ne_count -= n;
return n;
}
NE_API int ne_lost_count(void) { return ne_lost; }
/* a 64-bit value round trip: handles and counts wider than 32 bits cross as long */
NE_API int64_t ne_twice(int64_t x) { return x * 2; }

View file

@ -0,0 +1,8 @@
# ludic.nativeecho - the worked example of a package that carries a native library (phase 15):
# a C function that sums a float slice, and one that records what a callback would have said
# into a buffer the package drains. Uses ludic.base and nothing else.
package "ludic.nativeecho"
version "0.1.0"
kind source
native "macos-arm64" "lib/macos-arm64/libnativeecho.dylib"
native "windows-x64" "lib/windows-x64/nativeecho.dll"

View file

@ -0,0 +1,41 @@
# echo_test.ludic - the native library is linked and called: a sum over a slice, a scan whose
# records come back as facts in order, a 64-bit value, and a drain that leaves nothing behind
import "ludic.nativeecho"
import "ludic.base"
program EchoTest {
numbers float
test "a sum is worked out in C over the package's own slice" (echo_st: mut EchoState) {
echo_put(echo_st, 1.5)
echo_put(echo_st, 2.0)
echo_put(echo_st, 0.25)
expect(echo_sum(echo_st) == 3.75)
}
test "what C would have called back arrives as facts, in order" (echo_st: mut EchoState) {
echo_put(echo_st, 1.0)
echo_put(echo_st, 9.0)
echo_put(echo_st, 2.0)
echo_put(echo_st, 7.5)
expect(echo_scan(echo_st, 5.0) == 2)
let fs = q_drain(echo_facts(echo_st))
expect(len(fs) == 2)
expect(fs[0].index == 1)
expect(fs[0].value == 9.0)
expect(fs[1].index == 3)
expect(fs[1].value == 7.5)
expect(echo_scan(echo_st, 100.0) == 0)
expect(q_len(echo_facts(echo_st)) == 0)
expect(echo_lost() == 0)
}
test "a long crosses whole" {
expect(echo_twice(long(3000000000)) == long(6000000000))
}
test "the slice holds what it was made for and no more" (echo_st: mut EchoState) {
for i in 0 .. ECHO_CAP { expect(echo_put(echo_st, 1.0)) }
expect(not echo_put(echo_st, 1.0))
expect(echo_sum(echo_st) == float(ECHO_CAP))
}
}