Files
scratch-vm/docs/compiled-projects.md
T

6.0 KiB

Compiled projects

Compiled project packages are source-stripped, read-only project archives. They retain runtime state and assets, but replace script bodies and custom block definitions with compiled JavaScript factories. Hat blocks and the minimum input metadata needed for event matching remain in project.json.

Compiled artifacts contain executable JavaScript. Only load artifacts and packages produced by a trusted compiler. The legacy sourceHash and runtimeProjectHash checks bind the artifact to its source graph and stripped project.json; they do not cover the whole archive or authenticate a publisher. New packages therefore include a SHA-256 integrity manifest by default.

Create a transportable artifact

After loading a project and all of its extensions, enable the compiler and export the artifact:

vm.setCompilerOptions({enabled: true});
await vm.loadProject(sb3Data);

const artifact = vm.exportCompiledProject();
const serialized = JSON.stringify(artifact);

Protection pipelines which derive build material from project metadata should use the paired export. Both values are clones, and project is the complete, unstripped project.json object:

const {artifact, project} = vm.exportCompiledProjectBuildInput();

Artifacts may declare external values required by transformed compiled code in a top-level requiredBindings array. Native VM exports use an empty array. A loader must provide every declared value through the bindings option:

playerVM.loadCompiledProject(artifact, {
    bindings: {
        __vmAccess: accessor
    }
});

Bindings are trusted runtime dependencies, not a sandbox boundary. The VM injects only names declared by the artifact; other properties in the supplied object are ignored.

The receiving VM must first load the matching full project before loading this artifact:

await playerVM.loadProject(sb3Data);
playerVM.loadCompiledProject(JSON.parse(serialized));

The loader validates the format version, compiler version, runtime ABI, targets, and serialized block graph before installing any compiled functions.

Create a source-stripped package

Use saveCompiledProject() when the player should not receive the original block graph:

const packageData = await vm.saveCompiledProject('arraybuffer');

By default this also creates compiled-project.integrity.json. Its sorted SHA-256 entries cover every final package file other than the manifest itself. Packaging fails if a costume, sound, or custom-font reference is missing from the archive. Use {integrity: false} only when intentionally creating a legacy-compatible package without a manifest.

The resulting zip contains:

  • project.json with runtime state, assets metadata, extension dependencies, and minimal hat metadata;
  • compiled-project.json with compiled script factories and compatibility metadata;
  • the same costume, sound, and font assets as the source project.

Load it using the dedicated package API:

await playerVM.loadCompiledProjectPackage(packageData);
playerVM.greenFlag();

If a deployment requires a manifest and publisher signature, provide asynchronous signing and verification adapters:

const packageData = await vm.saveCompiledProject('arraybuffer', {
    artifact,
    integrity: {signManifest}
});

await playerVM.loadCompiledProjectPackage(packageData, {
    bindings,
    requireIntegrity: true,
    requireSignature: true,
    verifyIntegritySignature
});

signManifest({payload, manifest}) returns {algorithm, keyId, value}. payload is the canonical UTF-8 manifest without signature. A signed package is always rejected when no verifier is supplied, even when signature enforcement was not explicitly requested. Manifest, file, or signature failures happen before project state is changed.

An external compiler transform can be packaged without rebuilding the original scripts:

const artifact = vm.exportCompiledProject();
const transformed = transformCompiledArtifact(artifact);
const packageData = await vm.saveCompiledProject('arraybuffer', {
    artifact: transformed
});

The supplied artifact must match the currently loaded project, compiler format, and runtime ABI. Packages whose artifact declares runtime bindings must receive them before any project state is installed:

await playerVM.loadCompiledProjectPackage(packageData, {
    bindings: {
        __vmAccess: accessor
    }
});

The package loader verifies the metadata before modifying the VM, loads required extensions, enables compiled execution, installs every compiled script, and only then emits PROJECT_LOADED.

This format removes the original Scratch block source but does not make client-side behavior impossible to reverse engineer. Generated JavaScript, literal values, runtime calls, and extension implementations remain observable to the client.

Salted opcode lookup for external transforms

The compiler continues to emit runtime.getOpcodeFunction(opcode) for compatibility. External trusted transforms can remove literal opcode names by deriving a 48-bit ID and calling runtime.getOpcodeFunctionById(id, salt) instead. The ID is the first six bytes, interpreted as a big-endian integer, of SHA-256("sb3c-opcode-v1\0" + salt + "\0" + opcode).

id must be a non-negative JavaScript safe integer and salt must be a non-empty string. Invalid inputs throw a TypeError; an unknown ID returns undefined, matching the legacy lookup. The runtime caches the reverse mapping for one salt, invalidates it when primitives are registered or refreshed, and rejects collisions between registered opcodes. Salts are public build inputs rather than secrets; salted IDs increase static analysis cost but do not encrypt extension behavior.

Try the two-stage playground

Run npm start, then open http://localhost:8073/playground/compiled-project.html. The left side loads a full SB3 and creates a source-stripped .sb3c package. The right side uses a separate VM to load and run either the in-memory result or a previously downloaded package, which models the compiler and player as separate processes.