Your application is C or C++. It owns main, the window, the frame
loop, and the GPU objects that outlive a frame. A script owns
encoding. The script programs against the WebGPU JS API shape —
requestAdapter, createBuffer, beginRenderPass — and reaches the
GPU through a facade you link, not through anything you write.
A C host runs the ship tier: subscript emit writes the program
as one C translation unit, and you compile it like your own sources.
The runtime header declares no entry point that loads script source
at run time, so there is no dynamic-load path to look for. The
development tier, with the in-process JIT and hot reload, needs a
Rust host — see gpu/docs/tutorial-rust.md.
Read this first, plainly:
- This repository ships no C host. Invariant 7 admits only
generated C in the tree, so the host code below lives in this
document alone. It is not under the snippet gate that pins the rest
of the documentation. The compute-host commands, diagnostics and
outputs come from a run against commit
a21a5db, recorded inspecs/tracking/p6-host-embedding.md. The frame-hostcheckandemitcommands in step 7, and the emitted export signatures they show, come from a run against the current tree. No full frame C host run exists for this document — the frame host this repository ships and gates is the Rust one. - You supply three things this repository does not. The first is
a yawgpu build — on macOS
libyawgpu.dylibwithlibtint_shim.dylibbeside it. The second is thesubscriptcommand, built from the revision pinned inspecs/tracking/pins.md. The third is the subscript runtime archive and its include directory. - Lifetimes are manual. Create-owns plus explicit release, and no finalizers. Your host releases what your host created.
- Every command here targets a Unix host. MSVC names an archive
<name>.lib, takes the import library by path, and has no rpath. The suite passes on Windows, andspecs/tracking/windows-msvc.mdrecords the differences. No Windows host run exists for this document.
The scripts below are committed and gate-pinned: the compute
program
gpu/programs/a27-host-compute.ts,
which produced its committed golden under a C host, and the windowed
example's frame script
gpu/examples/windowed-triangle/frame.ts.
Set the environment once. The first variable selects the backend library, and the last two tell the toolchain where the runtime is:
export SUBSCRIPT_GPU_BACKEND_LIB_DIR=<directory that holds the backend library>
export SUBSCRIPT_RUNTIME_INCLUDE=<subscript checkout>/runtime/include
export SUBSCRIPT_RUNTIME_LIB=<subscript checkout>/target/release/libsubscript_runtime.aBuild the three archives a host links. Run these from the repository root:
cargo build --offline -p subscript-gpu-facade --features backend-yawgpu
cargo build --offline -p subscript-gpu-engine
cargo build --offline -p subscript-runtimeThey write target/debug/libsubscript_gpu_facade.a,
libsubscript_gpu_engine.a and libsubscript_runtime.a. If
SUBSCRIPT_GPU_BACKEND_LIB_DIR is unset, the first build fails and
says so:
subscript-gpu-facade: cannot resolve the backend library directory for `yawgpu`.
Set SUBSCRIPT_GPU_BACKEND_LIB_DIR to the directory containing the backend
library, or make `pkg-config --variable=libdir yawgpu` succeed.
subscript link-flags prints the runtime include flag and the
archive path, for a build system that prefers to ask:
$ subscript link-flags
-I<subscript checkout>/runtime/include
<subscript checkout>/target/release/libsubscript_runtime.aA script imports the API layer as a same-directory sibling
(import { … } from "./webgpu"). Copy the layer next to your script:
mkdir -p app build host
cp gpu/api/webgpu.ts app/
cp gpu/programs/a27-host-compute.ts app/compute.ts$ subscript check app/compute.ts --mirror gpu/mirror/sgpu.generated.d.ts
check: app/compute.ts: no errorsOne --mirror per ambient file. This program needs only the facade
mirror, because the script creates every GPU object itself.
subscript emit app/compute.ts --mirror gpu/mirror/sgpu.generated.d.ts \
--no-entry -o build/gen/This writes build/gen/program.c and build/gen/program.alloc.h.
--no-entry omits the generated main, because your host supplies
it.
The script owns every GPU object here, so the host only steps the script's async work:
/* host/compute_main.c */
#include "subscript_runtime.h"
#include <stdio.h>
int main(void) {
subscript_rt_context *ctx = subscript_rt_ctx_new();
subscript_init(ctx);
subscript_rt_ctx_async_step(ctx);
subscript_rt_ctx_enter_script(ctx);
subscript_export_main(ctx);
subscript_rt_ctx_exit_script(ctx);
while (subscript_rt_ctx_async_pending(ctx) != 0u) {
subscript_rt_ctx_async_step(ctx);
}
if (subscript_rt_ctx_trap_kind(ctx) != 0u) {
uint64_t message_len = 0;
const uint8_t *message = subscript_rt_ctx_trap_message(ctx, &message_len);
fprintf(stderr, "script trapped: %.*s\n", (int)message_len, message);
subscript_rt_ctx_release(ctx);
return 1;
}
uint64_t out_len = 0;
const uint8_t *out = subscript_rt_ctx_stdout(ctx, &out_len);
fwrite(out, 1, (size_t)out_len, stdout);
subscript_rt_ctx_release(ctx);
return 0;
}Five facts make this the whole protocol:
- One Context owns every script allocation, and
subscript_rt_ctx_releasefrees them together. subscript_initruns once per Context, before any entry.subscript_rt_ctx_enter_scriptand_exit_scriptbracket each entry call.- An
asyncentry parks at its firstawait. Onlysubscript_rt_ctx_async_stepresumes it, andsubscript_rt_ctx_async_pendingreports what remains. - Nothing unwinds across the boundary. A script fault records a trap, and trap kind 0 means the run completed.
cc -std=c11 -O2 \
-I"$SUBSCRIPT_RUNTIME_INCLUDE" -Ifacade \
build/gen/program.c host/compute_main.c \
target/debug/libsubscript_gpu_facade.a \
"$SUBSCRIPT_RUNTIME_LIB" \
-L"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" -lyawgpu \
-Wl,-rpath,"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" \
-o build/compute_host./build/compute_host prints the committed golden:
dispatch:encoded-workgroups=4
completion:submitted=true:mapped=true
readback:observed=1,2,3,4
resources:disposed
The script binds its resources with using (TS 5.2 explicit
resource management), so disposal is scope-exit logic inside the
script. The C host neither sees nor steps it, and the output above
is unchanged.
The read-back bytes are the pre-dispatch contents. The headless substrate records a dispatch and does not execute the shader, so computed values need a real device.
A host that owns GPU objects presents them to the script through its own C header. Write the header, and follow three rules:
- Handles are opaque pointer typedefs
(
typedef struct EngineImpl *EngineHandle;). - A type the sgpu mirror already declares is marked external, so the
binder references it instead of a second declaration of it:
/* @subscript-external SGPUTextureView */. - The lifecycle pair stays out of the header. A script cannot own what it cannot name.
This repository generates that header from Rust, and the result is the shape a hand-written one takes. In the push shape your header carries only scalar queries and input recording — the handles reach the script as entry parameters, and this repository's header keeps its handle-returning functions for the differential suite's programs, which the frame script never calls:
/* @subscript-external SGPUDevice */
/* @subscript-external SGPUInstance */
/* @subscript-external SGPUTextureView */
typedef struct EngineImpl *EngineHandle;
uint32_t engineReady(EngineHandle engine);
EngineHandle engineCurrent(void);
SGPUInstance engineAcquireInstance(EngineHandle engine);
SGPUDevice engineAcquireDevice(EngineHandle engine);
SGPUTextureView engineAcquireFrameView(EngineHandle engine);
void engineProcessEvents(EngineHandle engine);
uint32_t engineFrameWidth(EngineHandle _engine);
uint32_t engineFrameHeight(EngineHandle _engine);
uint32_t engineReadbackFrame(EngineHandle engine);
uint32_t engineFrameSample(EngineHandle engine, uint32_t x, uint32_t y);
int32_t engineValue(EngineHandle engine);subscript bind turns it into an ambient mirror. The command takes
no include path, and libclang resolves #include "sgpu.h" beside the
header, so put both headers in one directory and run the command
there:
mkdir -p build/headers
cp gpu/facade/sgpu.h gpu/engine/engine.h build/headers/
cd build/headers && subscript bind --header engine.h -o engine.generated.d.tsThe output is byte-identical to this repository's committed
gpu/mirror/engine.generated.d.ts. One header therefore serves a Rust
host and a C host, with no second source of truth.
A frame host owns the window, the device, and the loop, and the
script receives what it needs as entry parameters. The committed
script is the windowed example's frame.ts. Check and emit it with
both mirrors:
cp gpu/examples/windowed-triangle/frame.ts app/frame.ts
subscript check app/frame.ts --mirror gpu/mirror/sgpu.generated.d.ts \
--mirror gpu/mirror/engine.generated.d.ts \
--mirror gpu/mirror/wire-enum-aliases.generated.d.ts
subscript emit app/frame.ts --mirror gpu/mirror/sgpu.generated.d.ts \
--mirror gpu/mirror/engine.generated.d.ts \
--mirror gpu/mirror/wire-enum-aliases.generated.d.ts \
--no-entry -o build/gen-frame/The check reports no errors, and the third mirror declares the
wire-mapped enum aliases the other two reference.
Each exported host-callable entry becomes one C function in
build/gen-frame/program.c. The emitted signatures, from that run:
void subscript_export_init(subscript_rt_context* ctx, void* instance, void* device, int32_t format);
void subscript_export_frame(subscript_rt_context* ctx, void* view, uint32_t key);
void subscript_export_shutdown(subscript_rt_context* ctx);A handle crosses as void*, a scalar as its C type, and a
wire-mapped alias as int32_t. An unmapped wire value traps with
the alias name before the entry body runs, so the format the host
passes is validated at the crossing.
The host shape is the step-4 skeleton with the entry calls in place
of subscript_export_main. The host owns the instance, the device,
the surface, and the per-frame view — however it created them — and
pushes them in:
subscript_rt_ctx_enter_script(ctx);
subscript_export_init(ctx, instance, device, surface_format);
subscript_rt_ctx_exit_script(ctx);
for (;;) {
/* acquire the frame's view, translate input, then: */
subscript_rt_ctx_enter_script(ctx);
subscript_export_frame(ctx, view, key);
subscript_rt_ctx_exit_script(ctx);
/* present, release the frame's view, pump, step async */
}
subscript_rt_ctx_enter_script(ctx);
subscript_export_shutdown(ctx);
subscript_rt_ctx_exit_script(ctx);Three rules carry the shape, and the script side of each is quoted
in the Rust document's walk of frame.ts:
- A parameter stays host-owned for the duration of the call. The
script wraps the device with
hostOwnedGPUDevice, and that wrapper exposes neitherdispose()nordestroy()— a wrong disposal fails atsubscript check, not at run time. - The script disposes only what it created.
shutdowncloses the script-owned pipeline, shader, and queue wrapper. The device and the per-frame view stay yours. - The view is frame-scoped. Pass a fresh view each frame and release it host-side after present. The script wraps it and never disposes it.
sgpu.h and engine.h carry no extern "C" guard, so a C++
translation unit must wrap them. Without the wrap the link fails and
names the cause:
Undefined symbols for architecture arm64:
"engineReady(EngineImpl*)", referenced from:
_main in main.o
NOTE: found '_engineReady' ... declaration possibly missing 'extern "C"'
Wrap both the include and your own declarations of the host-only functions:
extern "C" {
#include "engine.h"
}
extern "C" {
EngineHandle engineCreate(void);
void engineRelease(EngineHandle engine);
void engineHostPreEntry(void *context);
void engineHostPostRun(void *context);
}subscript_runtime.h guards itself, so it needs no wrap. Compile the
emitted program.c as C11 and your host as C++, then link them
together. The wrapped C++ host prints the same output as the C one.
- A Context belongs to one thread. Every
subscript_rt_*call and every entry call on one Context comes from one thread at a time. - Entries take no arguments. Data crosses through your own C surface. The host stages the frame's inputs in its engine before the call, and the script reads them through the mirror.
- A host pumps what it owns. The frame host pumps its instance.
The compute host owns no instance and pumps nothing, because the API
layer pumps the script's instance inside every
await. - Callbacks reach the script only when your thread calls. Every facade completion is a future the script polls after a pump. No callback arrives spontaneously.
- The
printsink is cumulative. A host that runs for a long time registers a print observer instead of a drain of the sink. - Release what you created, and only that. The script releases its
own wrappers.
hostOwnedGPUDevicereturns a host-owned wrapper with nodispose(). Itsqueue()method returns an owned wrapper that the script disposes.
- How to obtain a backend library. This repository neither vendors an implementation nor documents a build of one.
- Hot reload. In-place swap needs the JIT, which needs a Rust host.
- Presentation. The frame target is an offscreen texture, because a required gate must run with no window.
gpu/docs/tutorial-rust.md— the same two examples under a Rust host, on both tiers.specs/blocks/host-embedding.md— the contract these examples follow.gpu/facade/sgpu.h— the C surface the script reaches through, and the shape rules it obeys.