jaibite: Parse Once, Ship Precompiled​

A jai::jaibite is a script parsed and checked once, held as a handle to run many times. The same parsed form is what the engine caches on disk beside every script file, and what you can save and ship instead of source. The rules:

  • eng->jaibite(source) parses and checks; bite.execute() only runs. Parse errors and jai::static_check_error throw at creation, run-time errors at execute(). Behavior is identical to execute(string), including state kept across runs.
  • A live bite belongs to the engine that made it. It holds that engine's symbol ids, so never run it on another engine. Move a script between engines, processes or machines with save and jaibite_load, which re-intern every name into the loading engine.
  • A saved bite is the parsed AST, not bytecode. It serves both backends, and the VM compiles it lazily after a load, as it does a fresh parse.
  • The format is versioned with the parser. A bite is readable only by a build that shares its format version, so ship bites from the build you ship the engine from.
  • File loads are cached automatically. Most hosts never save a bite by hand.
C++
// 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 form

If the owning engine has been destroyed, bite.execute() throws script_exception. bite.valid() is false for a default-constructed bite.

When a jaibite pays​

Anything run per frame or per entity belongs in a bite: creation costs the parse once, and each run after that is execution only. The engine's 64-entry source cache gives a repeated execute(same_string) most of the same benefit; a bite is never evicted, and it can be saved. Performance has the measurements.

The automatic disk cache​

Whenever the engine loads a script file (execute_file, include, import), it keeps the parse in a .cache folder beside the source and loads that on the next launch instead of parsing: scripts/foo.jai gets scripts/.cache/foo.jai.jaibite. It is on by default, and the source on disk always wins. Add .cache/ to your .gitignore.

SituationWhat happens
The cache records this exact source text (by size and hash, never by modification time) and the engine's registration fingerprintLoaded; the parse is skipped. A trusted checked-clean stamp skips the static check too.
Different source text, a corrupt or truncated cache, a fingerprint mismatch, or another format versionThe source is parsed and the cache rewritten, silently. Hot reload is unaffected.
The file reopens a namespace and this engine resolves a bare class name in it differently than the cached parse didParsed again.
The cache cannot be written (a read-only asset folder)Never an error. The write is tried once per source text; eng->jaibite_cache_write_failures() counts them.
Two engines or processes share a cacheSafe: a cache is written to a temporary file and renamed into place.
execute(string)Never touches disk. A host that reads files through its own VFS keeps the cache by calling eng->execute_file_source(resolvedPath, content).
eng->jaibite_cache(false)Turns the cache off for that engine.
eng->disable(jai::filesystem) (Safety Rails)The cache is skipped, and jaibite_load(path) and bite.save(path) throw jai::runtime_error Insufficient privilege: filesystem. The byte forms save_bytes and jaibite_load_bytes still work.

Read-only sources: the cache directory​

Where scripts ship read-only (an iOS bundle, an Android APK, an installed game folder), point the cache at a folder the app can write:

C++
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
  • Each 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).
  • When the directory holds no valid cache for a source, a valid one in the .cache folder beside the source still loads and is never rewritten there. A build step that runs the scripts once therefore leaves .cache folders a bundle can ship, and the first launch skips the parse.
  • Sources the engine cannot open as files (APK assets passed to execute_file_source) have no .cache to read; their caches fill the directory on first launch.
  • An unwritable directory behaves like any failed write. An empty directory name restores the .cache folder beside each source.

Shipping .jaibite by hand​

For pack files, downloadable content or a bake step, save and load bites yourself. save and jaibite_load take explicit paths; jai::k_jaibite_extension (.jaibite) is the conventional extension. A loaded bite runs with the loading engine's registrations and globals:

C++
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();                                 // 7

jaibite_load and jaibite_load_bytes throw jai::runtime_error for a missing file, a bad format or corrupt data (jaibite: failed to open file: level.jaibite, jaibite: truncated header), and for a file from another format version: jaibite: unsupported format version M (expected N), where N is the build's jai::detail::k_jaibite_version.

Registration fingerprint​

engine::registration_fingerprint() hashes the registered surface by name: overloaded functions, plain global function registrations (with an arity-class marker), classes and template types. save stamps it into the header, and a load into an engine with a different fingerprint sets bite.registration_mismatch().

The flag is advisory. What a mismatch costs is a fresh check against the loading engine, so a name that engine lacks fails at load, before anything runs. Where the source put that use in a [[warn_mode]] region, the bite loads and the error surfaces only if the script reaches it:

C++
// 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'"

The checked-clean stamp​

Every bite carries its check report (Static Checking): bite.checked_clean() is true when it has no error-severity diagnostics, and bite.check_diagnostics() returns the check_report. save writes a checked-clean bit beside the fingerprint, so a bake step's check travels with the file.

A load trusts the stamp, and skips the check, only when all three hold. Otherwise the bite is checked again against the loading engine, and an error throws at load:

  • the registration fingerprint matches the saving engine's;
  • the stamp names this library's checker revision;
  • every name the check resolved outside the file resolves the same here. The stamp records each one: the unit that declared it, a global's type, and a host function's overloads or a bound class's methods, fields and bases by their C++ signatures.

So a file that checked clean inside one program, with its names declared by a sibling unit, is checked again when a different program reaches it or the sibling's declaration changes, and gets the verdict a fresh parse would. A clean re-check rewrites the disk cache under the new context.

  • A bite is checked once. Registrations that arrive later do not re-check it: make the bite again. The checker's type proofs are independent of the stamp, and a loaded bite derives them again.
  • Bare class names in a namespace. A parse that read Point inside namespace geo as geo::Point (or not) 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, so jaibite_load refuses it with a jai::static_check_error naming 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.