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:
parent
5df0747642
commit
5d3a799e33
25 changed files with 54474 additions and 51707 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
26
packages/ludic.nativeecho/README.md
Normal file
26
packages/ludic.nativeecho/README.md
Normal 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.
|
||||
69
packages/ludic.nativeecho/echo.ludic
Normal file
69
packages/ludic.nativeecho/echo.ludic
Normal 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) }
|
||||
7
packages/ludic.nativeecho/index.ludic
Normal file
7
packages/ludic.nativeecho/index.ludic
Normal 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
BIN
packages/ludic.nativeecho/lib/macos-arm64/libnativeecho.dylib
(Stored with Git LFS)
Executable file
Binary file not shown.
7
packages/ludic.nativeecho/native.ludic
Normal file
7
packages/ludic.nativeecho/native.ludic
Normal 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"
|
||||
10
packages/ludic.nativeecho/native/build.sh
Executable file
10
packages/ludic.nativeecho/native/build.sh
Executable 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"
|
||||
59
packages/ludic.nativeecho/native/shim/nativeecho.c
Normal file
59
packages/ludic.nativeecho/native/shim/nativeecho.c
Normal 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; }
|
||||
8
packages/ludic.nativeecho/package.ludic
Normal file
8
packages/ludic.nativeecho/package.ludic
Normal 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"
|
||||
41
packages/ludic.nativeecho/tests/echo_test.ludic
Normal file
41
packages/ludic.nativeecho/tests/echo_test.ludic
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue