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 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. Every command, diagnostic and output here
comes from a run against commit
a21a5db, recorded inspecs/tracking/p6-host-embedding.md. - 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 two programs below are this repository's committed examples,
programs/a27-host-compute.ts and
programs/a28-host-triangle.ts.
Both produce their committed goldens under a C host.
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 api/webgpu.ts app/
cp programs/a27-host-compute.ts app/compute.ts$ subscript check app/compute.ts --mirror 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 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:released
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:
/* @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 facade/sgpu.h 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
mirror/engine.generated.d.ts. One header therefore serves a Rust
host and a C host, with no second source of truth.
The frame program borrows the host's device and renders into a host-owned offscreen target. Check and emit it with both mirrors:
cp programs/a28-host-triangle.ts app/frame.ts
subscript check app/frame.ts --mirror mirror/sgpu.generated.d.ts \
--mirror build/headers/engine.generated.d.ts \
--mirror mirror/wire-enum-aliases.generated.d.ts
subscript emit app/frame.ts --mirror mirror/sgpu.generated.d.ts \
--mirror build/headers/engine.generated.d.ts \
--mirror mirror/wire-enum-aliases.generated.d.ts \
--no-entry -o build/gen-frame/The third mirror declares the wire-mapped enum aliases. The engine
mirror types engineFrameFormat with one of them, so a check
without it fails with unknown type name GPUTextureFormat``.
The host creates the engine before the entry, pumps it once per frame, and releases it after the loop:
/* host/frame_main.c */
#include "subscript_runtime.h"
#include "engine.h"
#include <stdio.h>
/* Host-only lifecycle: absent from engine.h, so no script can name it. */
EngineHandle engineCreate(void);
void engineRelease(EngineHandle engine);
void engineHostPreEntry(void *context);
void engineHostPostRun(void *context);
int main(void) {
subscript_rt_context *ctx = subscript_rt_ctx_new();
subscript_init(ctx);
engineHostPreEntry(NULL);
EngineHandle engine = engineCurrent();
if (engineReady(engine) != 1u) {
fprintf(stderr, "host: engine creation failed\n");
subscript_rt_ctx_release(ctx);
return 1;
}
for (int frame = 0; frame < 2; frame += 1) {
engineProcessEvents(engine);
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) {
engineProcessEvents(engine);
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);
}
uint64_t out_len = 0;
const uint8_t *out = subscript_rt_ctx_stdout(ctx, &out_len);
fwrite(out, 1, (size_t)out_len, stdout);
engineHostPostRun(NULL);
subscript_rt_ctx_release(ctx);
return 0;
}The host declares the lifecycle itself, because rule 3 keeps it out
of engine.h. In this repository the pair is spelled
engineHostPreEntry and engineHostPostRun, and it also publishes
the handle that engineCurrent() returns. In your own engine the
pair is whatever your create and release functions are called.
Link the engine archive as well, and add its include directory:
cc -std=c11 -O2 \
-I"$SUBSCRIPT_RUNTIME_INCLUDE" -Ifacade -Iengine \
build/gen-frame/program.c host/frame_main.c \
target/debug/libsubscript_gpu_engine.a \
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/frame_host./build/frame_host prints these six lines once per frame:
frame:target=64x64
render:encoded-clear=0,0,0.25,1:draw=3
completion:submitted=true
readback:ready=true
samples:device-only
resources:released
samples:device-only is the headless answer. The substrate records
draw calls and does not rasterise them, so a headless run proves
argument conversion, lifetimes and completion delivery, and proves
nothing about pixels. Select a real backend with
SUBSCRIPT_GPU_BACKEND=metal, and the same line becomes
samples:covered=true:not-covered=true.
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. A wrapper built by
borrowGPUDevicereleases the queue state it acquired and never the borrowed device.
- 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.
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.facade/sgpu.h— the C surface the script reaches through, and the shape rules it obeys.