jaibite: Parse Once, Ship Precompiled
A jai::jaibite (include/jaiscript/core/jaibite.hpp) is an engine-bound, pre-parsed
script handle: engine->jaibite(source) lexes and parses once; every
bite.execute() after that skips straight to execution. On the VM backend the bite also
caches its compiled bytecode chunk, so re-execution is pure dispatch. It buys you two things:
scripts you run repeatedly stop paying the parse, and shipped games boot from precompiled bites
instead of source, usually without you managing a single file, because the engine maintains the
disk cache for you (below).
// compiled and run against the library
eng->add_function("host_bonus", [](jai::script_int base) -> jai::script_int { return base + 100; });
eng->execute("var acc = 0;");
jai::jaibite bite = eng->jaibite("acc = host_bonus(acc); acc"); // parse + check ONCE (both throw here)
bite.execute(); // 100
bite.execute(); // 200 — engine state persists across runs
eng->execute(bite); // 300 — equivalent engine-side formOutput: run1: 100, run2: 200, run3: 300.
Semantics are guaranteed identical to execute(string): both routes funnel through the same
private execution pipeline, so the pre-parsed path can never drift semantically. Everything that
works across repeated execute() calls (state persistence, class redefinition, try/catch,
coroutine handles) works identically across repeated bite.execute() runs. Both backends
behave identically here; the test suite holds them to it.
The rules:
- The one rule: never share a live
jaibiteacross engines. A bite carries interned symbol IDs that are specific to the engine that parsed it. To move a script between engines (or processes, or machines) go throughsave()/jaibite_load(). The save/load path relocates every symbol into the loading engine. Everything else about bites is convenience; this rule is the contract. - A bite in hand has already passed the front end. Parse errors throw at
jaibite()creation, and so do static-check errors. Checking is strict, sojaibite()throwsjai::static_check_errorat creation exactly likeexecute()does (Static Checking). Runtime errors throw atexecute(). - If the owning engine has been destroyed,
bite.execute()throwsscript_exceptionrather than crashing. bite.valid(): false for a default-constructed/empty bite.
When a jaibite pays
Creation costs the parse: once, and backend-identical. After that, a warm
bite.execute() is the cheapest way the engine offers to run script, and the VM's
one-time compile (a fraction of the parse) amortizes within the first few executions. Anything you run
per-frame or per-entity belongs in a bite; the measured regime table is in
Performance. (The engine's 64-entry LRU
source cache gives repeated execute(same_string) most of the same win without the handle;
the bite adds determinism, with no cache eviction, and save/load.)
Instant boot: the automatic disk cache
You usually don't need to ship bites by hand at all. Whenever the engine loads a script
file (execute_file, script-side include in all forms, script-side
import), it keeps the parse in a .cache folder beside the source
(scripts/foo.jai gets scripts/.cache/foo.jai.jaibite, the folder made when missing;
default ON), and next launch loads that instead of parsing. The parse simply leaves the boot path: a measured game
file's load-and-run drops to well under half, the parse replaced by a small binary read (numbers in
Performance). Across a game's whole script tree,
that is the difference between a boot that parses and a boot that doesn't.
The cache is designed so you can forget it exists:
- Matching cache (its header records the exact source text, by size and hash, and
the registration fingerprint matches): loaded instead of parsed, and the parse is skipped entirely. Mtimes
never decide: a source copied into place with an older timestamp is judged by its bytes. A trusted
checked-clean stamp also skips the static re-check, exactly like
jaibite_load. A parse of a unit that reopens a namespace records the namespace classes it looked up (a barePointinsidenamespace georeads asgeo::Pointwhere the engine holds one); a cache whose lookups this engine answers otherwise is parsed again. - Anything else (other source text, corrupt/truncated cache, fingerprint mismatch)
silently falls back to parsing the source and rewrites the cache. The
.jaion disk always wins; hot reload is unaffected. The cache is written to a temporary file and renamed into place, so another engine or process never reads part of one. - Write failures are silent and tolerated (read-only asset dirs, shipped games): the
cache is an optimization, never an error, and a source text that could not write its cache does not try
again on every load.
eng->jaibite_cache_write_failures()counts them for diagnostics. - String executes never touch disk; hosts that read files through their own VFS can
route through
eng->execute_file_source(resolvedPath, content)to keep the cache. Opt out per engine witheng->jaibite_cache(false). An engine whose filesystem privilege is disabled (eng->disable(jai::filesystem), Safety Rails) skips the cache entirely, and an explicitjaibite_load(path)orbite.save(path)throws ajai::runtime_errorreadingInsufficient privilege: filesystem.; the byte forms (save_bytes,jaibite_load_bytes) still work.
The cache stores the parsed AST, so it is backend-agnostic: both backends skip the parse, and the VM
reuses its lazily compiled chunk in-memory as always. The whole matrix (a matching cache loading
while the source is unparseable, older-mtime and same-mtime replacements, corrupt and truncated
fallback and rewrite, fingerprint mismatch, unwritable caches, a cache directory and a shipped
.cache) is covered by the test suite. Add .cache/ to your .gitignore.
Read-only sources: the cache directory
Where the scripts ship read-only (an iOS app bundle, an Android APK, an installed game folder), point the cache at a folder the app can write:
eng->jaibite_cache_directory(eng->app_cache_dir("MyGame") + "/scripts", bundle_root);
eng->jaibite_cache_path(bundle_root + "/scripts/foo.jai"); // <cache dir>/scripts/foo.jai.jaibite- Every cache is written below the directory at the source's path relative to the root. A source outside the root, or any source when no root is given, mirrors its own path (a relative path as given, a drive as the first folder), so a cache path depends only on the source path.
- When the directory holds no valid cache for a source, a valid one in the
.cachefolder beside the source still loads, and is never rewritten there: a build step that runs the scripts once leaves.cachefolders a bundle can ship, and the first launch skips the parse. Sources the engine cannot open as files (an APK's assets, read by the host and passed toexecute_file_source) have no readable.cache; their caches fill the directory on first launch. - An unwritable directory costs nothing: the parse runs in memory, the write is tried once per source text and
counted, never reported. An empty directory restores the
.cachefolder beside each source.
Shipping .jaibite by hand
When you want explicit control (pack files, downloadable content, a bake step), save and load
bites yourself. The canonical extension is jai::k_jaibite_extension (.jaibite);
save()/jaibite_load() take explicit paths, so the extension there is a
convention.
std::vector<uint8_t> bytes = bite.save_bytes(); // or bite.save("level.jaibite")
auto other = jai::engine::make(); // a DIFFERENT engine (other process, other run)
jai::stdlib::register_all(other);
other->add_function("host_bonus", [](jai::script_int base) -> jai::script_int { return base + 7; });
other->execute("var acc = 0;");
auto loaded = other->jaibite_load_bytes(bytes); // or other->jaibite_load(path)
loaded.execute(); // 7A bite saved from one engine (a hundred-odd bytes for the demo script) loads into a fresh engine
and runs, using the loading engine's registrations and globals: the host_bonus
registered there returns 7.
What is serialized is the parsed AST, never the compiled chunk. A loaded bite
recompiles lazily like a fresh one. Loading re-interns every symbol and type name into the
loading engine, which is exactly why one .jaibite file serves any engine while live
bites serve only their own. save_bytes() exists specifically for host VFS/pack-file routing.
jaibite_load* throws jai::runtime_error on a missing file, bad format, version
mismatch, or corrupt data (jaibite: failed to open file: level.jaibite,
jaibite: truncated header, ...).
The format is versioned, and the number moves whenever the
parser gains a node, so a bite is readable only by builds that share its format version. An explicit
jaibite_load* of a file from another version throws jaibite: unsupported format version M (expected N), where M is the file's version and N the build's, jai::detail::k_jaibite_version;
a stale file in the automatic disk cache is simply reparsed and rewritten, silently. Practical
rule: ship bites from the same build you ship the engine from, and never hand-carry a
.jaibite across versions.
The guarantee behind all of this is differential: for every script shape (functions, defaults, ref
params, classes with inheritance and statics, lambdas with captures, coroutines, containers,
try/catch, uncaught-throw error text, registered C++ types), a fresh-parsed bite and a
load(save(bite)) bite in a different engine must produce identical results
and identical error text, twice each, on both backends: a standing test battery, not a
design intention.
Registration fingerprint
A bite compiled against your engine's API surface may land in an engine with a different one.
engine::registration_fingerprint() hashes the registered surface: overloaded functions,
plain global function registrations (with an arity-class marker), classes, and template types.
save_bytes() stamps it into the header; jaibite_load* compares it against the
loading engine and sets bite.registration_mismatch() on difference.
The flag itself is advisory; what a mismatch costs is a fresh check. The bite's checked-clean
stamp is trusted only under a matching fingerprint (below), so a mismatched bite is re-checked against
the loading engine at load, and a name the loading engine does not have fails there, before anything runs.
A bite whose source put that use in a [[warn_mode]] region loads and runs, and the error surfaces
at execute() only if the script actually touches the missing registration:
// compiled and run against the library
auto bare = jai::engine::make(); // no host_bonus registered
jai::stdlib::register_all(bare);
bare->execute("var acc = 0;");
bare->jaibite_load_bytes(bytes); // throws jai::static_check_error
// static check failed:
// <script>:1:7: error: use of undeclared identifier 'host_bonus'
auto risky = bare->jaibite_load_bytes(warn_region_bytes); // the same source, saved as "[[warn_mode]]; ..."
risky.registration_mismatch(); // true
risky.execute(); // throws "Undefined variable 'host_bonus'"Checked-clean stamp (static checking integration)
Checking is always on, so this applies to every bite you make. An error makes jaibite() throw
jai::static_check_error at creation, the same gate execute() passes through, and the
report rides the bite (see Static Checking):
bite.checked_clean(): checked, no error-severity diagnostics.bite.check_diagnostics(): thecheck_report.save()stamps a checked-clean bit beside the fingerprint, so a bake step's strict check travels with the file.- On load the stamp is trusted only when the registration fingerprint matches the saving engine, the stamp names this library's checker revision (a newer checker has rules an older stamp never ran) and the declarations the check resolved outside the file mean the same in the loading engine (below); otherwise the bite is re-checked against the loading engine's surface (an error throws at load).
- A bite is checked once. Registrations arriving later don't re-trigger; re-create the bite to re-check. Parse-time type proofs (fast-path annotations the checker stamps on the AST, nothing you configure; see Static Checking, Proofs) are independent of the stamp: a loaded bite re-derives them.
- The stamp holds for the context the file was checked in. It records every name the check resolved outside the file (a global, function, class or enum another unit of the program declares or the engine holds, a namespace and its members) and what each resolved to: the unit that declared it, a cell's type, a script declaration by the unit that ran it, a host function's overloads and a bound class's methods, fields and bases by their C++ signatures (the registration fingerprint holds only their names). A load trusts the stamp only where each of those names resolves the same, so a leaf that checked clean inside one program (its names declared by a sibling unit) is checked again when a different program reaches it, or when the sibling's declaration changes, and gets exactly the verdict a fresh parse would. A re-check that comes out clean writes the cache again under the new context.
- A parse that read a bare class name inside a namespace (
Pointinsidenamespace geoisgeo::Pointwhere the engine holds one) holds only where the loading engine holds the same namespace classes. The disk cache parses such a file again; a saved bite has no text to parse, sojaibite_load*refuses it with ajai::static_check_errornaming each class (the bite was parsed where no namespace declared class 'Point'; here one does: save it again from source). Load it where the namespaces it was saved under have run.