TBCX

Tcl Bytecode eXchange tbcx 1.2.1 Aug 6, 2026

NAME

tbcx — serialize, load, and inspect precompiled Tcl 9.1 bytecode (procs, OO methods, and lambdas). Artifacts require an exact Tcl major/minor/patch/release-type match at load time.

SYNOPSIS

tbcx::save in out ?-include-source?
tbcx::load in
tbcx::dump filename
tbcx::gc

DESCRIPTION

The tbcx extension provides four commands that enable an efficient save → load → eval pipeline for Tcl 9.1 scripts.

The goal is to pay the cost of parsing/compiling at save time so that loading is as fast as reading a compact binary, while remaining functionally equivalent to source of the original script.

Artifacts store the compiled top-level, precompiled proc bodies, TclOO method/ctor/dtor bodies, and lambda literals for use with apply. Loading installs these into the current interpreter and executes the top-level block in the caller’s current namespace, with source-equivalent semantics for variable scope, frame identity, and info script.

In safe interpreters, tbcx_SafeInit provides the package and type infrastructure but does not register any tbcx::* commands. A parent interpreter may selectively grant access with interp alias or interp expose.

COMMANDS

tbcx::save in out ?-include-source?

Synopsis — Compile a script and write a .tbcx artifact.

Parameters

in — Resolved in this order:

  1. Open channel name — if the value names an existing open channel, it is read as text (the caller controls encoding; TBCX does not alter it).
  2. Readable file path — if the value is a path to a readable file, it is opened in text mode (default UTF-8), read, and closed by tbcx::save. The normalized path is recorded in the artifact header for info script restoration at load time.
  3. Literal script text — otherwise the value is treated as inline Tcl script text. Consequently, a value that looks like a path but is not currently readable is compiled as script text, not reported as a file-open error. No source path is recorded.

out — One of:

  • Writable channel — an open channel; binary mode (-translation binary -eofchar {}) is enforced. The caller’s channel settings are mutated and not restored. The channel is not closed.
  • Writable path — TBCX writes a temporary file in the target directory and renames it into place only after serialization succeeds, so a failed save never leaves a truncated artifact at the final path.

-include-source — Optional flag. Selects include-source policy and embeds the exact authored bytes in every executable-source field: proc, TclOO method, TBCX_LIT_BYTESRC, and lambda body. Use it when consumers depend on info body, info class definition, TIP #280 line attribution, disassembly annotations, or introspection-based clone idioms. Retained text is introspection data; execution still uses precompiled bytecode.

Default behavior (no -include-source): Selects strict compiled-only policy. Every proc, method, BYTESRC, and lambda executable-source field is empty. Proc and method introspection receives the diagnostic sentinel described in SOURCE PRESERVATION; stripped lambdas receive a separate list-shaped diagnostic representation. Ordinary Tcl literals remain in bytecode, so source stripping is not encryption.

Behavior
  • Performs a single-pass capture and rewrite of the script, extracting proc, namespace eval, oo::class create, oo::define/oo::objdefine, and self method definitions while producing a rewritten script with method bodies replaced by recognizable stub sentinels. Each captured method’s TclOO visibility is recorded in a per-record scope byte (default / public / unexported / TIP #500 true-private), derived from the -export/-unexport/-private definition options, lexical private { … } and self { … } context, and same-body export/unexport/self export/self unexport commands. An origin byte distinguishes class-definition methods (oo::define / oo::class create) from per-object methods (oo::objdefine).
  • Compiles the top-level script without borrowing the saver caller’s procedure-local slot table: the compile environment’s proc pointer is cleared and the caller frame’s LocalCache is hidden during compilation. At load time the block still executes in the load caller’s current namespace and frame.
  • Rejects, with a diagnostic, a self method (or a method inside a self { … } block) written in an oo::objdefine builder — oo::objdefine has no self method form, so such a definition cannot be reconstructed and would otherwise load as a silent no-op.
  • Pre-compiles namespace eval body literals and other script-body patterns (try/on/trap/finally, foreach, lmap, while, for, catch, if/elseif/else, eval, uplevel, time, timerate, dict for/map/update/with, lsort -command, and self method bodies inside oo::define).
  • Builds an occurrence-level executable-literal plan from generic invokeStk streams. The stack model identifies exact push instructions, so repeated equal scripts receive separate compiled records while equal data occurrences remain ordinary literals. Opcode handling uses an O(1) dispatch table covering Tcl 9.1 instructions.
  • Compiles nonzero-policy top-level, proc, method, lambda, and nested script blocks with generic command dispatch and marks them TCL_BYTECODE_PRECOMPILED.
  • Detects bytearray literals (bytes ≥ 0x80) and emits them as TBCX_LIT_BYTEARR to prevent UTF-8 encoding corruption.
  • Body literals are emitted as TBCX_LIT_BYTESRC with namespace-bound compiled bytecode and a policy-controlled source field. Statically recognized interp eval literal crossings are rejected with TBCX EVAL CROSSINTERP UNSUPPORTED; no source fallback is used.
  • When the input is a readable file path, the normalized path is recorded in the header via Tcl_FSGetNormalizedPath so the loader can restore info script to the authored value.
  • Serializes the header, top-level block, Procs table (with per-proc source-text LPString), Classes catalog (advisory, alphabetically sorted for deterministic output), and Methods table in definition order (with per-method scope byte, origin byte, source-text LPString, and kind 4 for self methods; ctor/dtor records always carry the default scope).
  • Lambda literals (apply forms) are compiled and serialized as lambda-bytecode literals.
  • Conflicting proc definitions across if/else branches are handled via indexed markers, enabling position-based matching at load time.
Returns
The output object: either the normalized path written, or the writable channel handle.
Errors
Typical errors include: read error from the input channel or opened file; unwritable output; unknown/unsupported AuxData; size limit exceeded; runaway serialization (too many literals, blocks, excessive recursion depth, or output too large); an unsupported statically recognized cross-interpreter script occurrence; or short read/write.
Notes
  • Input channels retain their encoding settings; output channels are set to binary mode (and not restored).
  • The produced artifact targets Tcl 9.1 (format version 93); other versions are rejected at load time.

Examples

# Save from path → path (default: source stripped)
set path [tbcx::save ./app.tcl ./app.tbcx]

# Save from path → path with body source preserved for
# info body / info class definition / TIP #280 line attribution.
tbcx::save ./app.tcl ./app.tbcx -include-source

# Save from string value → path
set script {proc hi {} {puts Hello}; hi}
tbcx::save $script ./hello.tbcx

# Save from channel → channel
set in  [open ./lib/foo.tcl r]
set out [open ./foo.tbcx w]
fconfigure $out -translation binary -eofchar {}
try {
    tbcx::save $in $out
} finally {
    close $in
    close $out
}

tbcx::load in

Synopsis — Load a .tbcx artifact, install precompiled entities, and execute the top-level block in the caller’s current namespace.

Parameters

in — One of:

  • Readable channel — an open channel positioned at the beginning of a .tbcx stream (binary).
  • Readable path — a filesystem path to a .tbcx file.
Behavior
  • Reads the bounded artifact into an owned, interpreter-neutral byte image and validates the header and exact packed producer version (major, minor, patch, and alpha/beta/final release type).
  • Completes a preflight pass over every section before constructing namespaces, Tcl objects, ByteCode, Proc, methods, or persistent shims. Preflight validates source policy and executable-source fields, opcodes and instruction boundaries, operands, literal/local/AuxData indices, jump targets, exception metadata, supported AuxData payloads, nested policy words, and exact end-of-file.
  • If the header carries a recorded authored source path, reads it into a Tcl_Obj for info script restoration.
  • Installs a temporary ProcShim that intercepts the proc command at the authored definition marker. When the name and argument signature match, it passes the already-deserialized procbody to Tcl’s original proc handler. Tcl therefore performs normal marker-time creation, replacement traces, namespace/export/import handling, and argument validation while reusing the serialized Proc and compiled-local chain. The body’s string representation is set to either the preserved source text (artifacts built with -include-source) or the diagnostic sentinel (default stripped mode).
  • Installs a temporary OOShim that intercepts oo::define and oo::objdefine by patching the commands’ handler pointers in place (the commands are not renamed) to substitute precompiled method/constructor/destructor bodies. Method records are keyed by (class FQN, kind, name, origin) and held in definition-order FIFOs; each definition site the rewritten script executes consumes the front record, and only bodies carrying the tbcx stub sentinel are patched — a method the saver left verbatim is never overwritten by a stale same-key record. After installing each body, the loader applies the record’s visibility scope (public, unexported, or TIP #500 true-private). Self methods (kind 4, TBCX_METH_SELF) are installed via oo::define { self method } to preserve metaclass inheritance for subclasses. Per-object methods are installed precompiled by replaying the oo::objdefine builder body against object-origin records; true-private per-object methods are re-created via the flat private form. Method body string representations follow the same preserved-or-sentinel rule as proc bodies.
  • The shims protect themselves with command traces on proc, oo::define, oo::objdefine, and apply: if any of these is renamed or deleted while a shim is active, the trace restores the original handlers in place immediately, so a renamed command never remains wired to freed shim state.
  • Registers precompiled lambdas in a persistent per-interpreter ApplyShim that recovers the compiled representation if type shimmer evicts it. A subsequent load reactivates the shim after apply is renamed, deleted, restored, or recreated.
  • Executes the precompiled top-level block via Tcl_EvalObjEx with flags 0 (no TCL_EVAL_GLOBAL), evaluating in the caller’s current namespace. iPtr->scriptFile is saved, set to the header’s source path (or the tbcx artifact path as a fallback when loading from a file), and restored after evaluation — matching Tcl_FSEvalFileEx’s handling in tclIOUtil.c. When loading from an already-open channel and the header carries no recorded path, info script is left untouched. A normal zero-local top-level leaves every field of the caller’s active frame untouched; a nonzero local overlay is resolved against the original caller frame before installation and that exact frame state is restored afterward. A top-level return is handled identically to source.
  • Removes the ProcShim and OOShim after execution; the ApplyShim persists for the interpreter’s lifetime.
Returns
The result of the top-level block evaluation.
Errors
Typical errors include: unreadable input; bad header; incompatible Tcl version; malformed/unknown section; size limit exceeded; short read; class or namespace creation errors during top-level evaluation.
Notes
  • The top-level block executes in the caller’s current namespace, with compiled locals linked to the caller’s variable frame. When tbcx::load is wrapped inside a proc that the user invokes externally, the caller should use uplevel 1 [list tbcx::load $path] to pop to the outer frame — identical to the pattern already required for source in the same position.
  • info script inside the loaded top-level block returns the authored .tcl source path when the artifact was built from a file, matching what source would have set. This supports both [file dirname [info script]] (for sibling asset lookup) and [info script] eq $::argv0 (self-invocation guards).
  • Loading executes code; only load artifacts from trusted sources.

Examples

# Load into current interp
tbcx::load ./app.tbcx

# Load from an open channel
set ch [open ./app.tbcx r]
fconfigure $ch -translation binary -eofchar {}
try {
    tbcx::load $ch
} finally {
    close $ch
}

# Drop-in replacement for source, inside a module loader proc.
# Both branches use uplevel 1 so they reach the caller’s frame.
proc moduleLoad {path} {
    set tbcxPath [file join [file dirname $path] ../tbcx [file tail $path].tbcx]
    if {[file exists $tbcxPath]} {
        uplevel 1 [list tbcx::load $tbcxPath]
    } else {
        uplevel 1 [list source $path]
    }
}

tbcx::dump filename

Synopsis — Disassemble and describe a .tbcx artifact in human-readable form.

Parameters
filename — A readable path to a .tbcx file.
Behavior
Validates through the same complete preflight path as tbcx::load and prints: header fields (including the authored source path and sourcePolicy); executable-source field, nonempty-field, and byte totals; section summaries; literals and AuxData; exception ranges; disassembly of the top-level, proc, method, and lambda bodies; and the preserved body source text (indented inline, no truncation) for each proc and method when the artifact was built with -include-source. For stripped artifacts (the default), each body record shows source: <stripped at save time>. Each method record also shows its visibility scope and origin (class-definition vs object-definition). Literal operands in the disassembly are annotated using the instruction table’s operand types rather than instruction-name heuristics.
Returns
A string containing the formatted dump.
Errors
Unreadable file; bad header; malformed section; short read.

Examples

% package require tbcx
% tbcx::save hello.tcl hello.tbcx -include-source
% puts [tbcx::dump hello.tbcx]
TBCX Header:
  magic = 0x58434254 ('T''B''C''X')
  format = 93
  tcl_version = 9.1.0 (type 0)
  top: code=12, except=0, lits=2, aux=0, locals=1, stack=2
  source = /path/to/hello.tcl

Top-level block:
  Disassembly (top-level):
    ...

Procs: 1
  - proc hi  (ns=::)
    args:
    source: (14 bytes)
      puts Hello
    Disassembly (hi):
    ...

tbcx::gc

Synopsis — Purge stale entries from the per-interpreter lambda shimmer-recovery registry.

Behavior
The ApplyShim maintains a registry of precompiled lambda objects. Over time, entries for lambdas that are no longer referenced (except by the registry itself) accumulate. This command purges those stale entries immediately. Stale entries are also purged lazily on each tbcx::load call, so explicit use of tbcx::gc is typically unnecessary except in long-running interpreters.

tbcx::gc is a no-op if no ApplyShim has been installed yet (i.e. before any tbcx::load call), and it is safe to call multiple times.

Parameters
None.
Returns
Empty string.

SOURCE PRESERVATION

Without -include-source, every designated executable-source field (proc, method/constructor/destructor, BYTESRC, and lambda body) is empty on the wire. Ordinary Tcl literals remain in bytecode, so this is not encryption. For proc and method bodies the loader installs this two-line diagnostic sentinel:

# tbcx: body source stripped at save time; info body unavailable
error "tbcx: introspection-based cloning is not supported for this artifact"

The first line is a Tcl comment visible to introspection and traces. The second line raises an error if code copies and evaluates the diagnostic body.

Stripped lambda records use a separate fixed list-shaped diagnostic string. Their registered lambdaExpr representation still holds the materialized Proc. A newly constructed object containing only the diagnostic has lost TBCX identity and fails instead of compiling an apparent authored body.

With -include-source, exact authored bytes are preserved in all four executable-source field kinds and attached for introspection as appropriate. The ByteCode internal representation is untouched and PRECOMPILED; execution still uses compiled material. info body, info class definition, info class constructor, and TIP #280 source attribution round-trip byte-for-byte.

FILE FORMAT (OVERVIEW)

This section summarizes the on-disk structure. Format version is 93 (Tcl 9.1). All integers are little-endian.

Header
Magic (0x58434254) + format version (93) + producing Tcl version; size/count metadata for the top-level block (code length, exception ranges, literal count, AuxData count, locals, max stack); authored source path LPString (empty for inline/channel inputs).
Sections (order)
(1) Top-level block (code, literals, AuxData, exceptions, locals epilogue including the source-policy u32; nested blocks keep that field zero); (2) Procs (FQN, ns, arg spec, body-source LPString, compiled block); (3) Classes (advisory catalog of discovered class names; actual creation occurs at load time via the top-level script); (4) Methods, in definition order (class FQN; kind u8: 0=inst, 1=class, 2=ctor, 3=dtor, 4=self; scope u8: 0=default, 1=public, 2=unexported, 3=TIP #500 true-private — ctor/dtor always 0; origin u8: 0=class definition, 1=object definition; name — empty for ctor/dtor; args; body-source LPString; compiled block). Records are keyed at load by (class FQN, kind, name, origin), so a class-instance method and a per-object method sharing a name coexist.
Literal kinds
Tagged u32 values: 0 bignum (sign u8, magnitude length u32, little-endian magnitude bytes), 1 boolean (one byte), 2 bytearray (length + raw bytes), 3 dict (pair count, then key/value literal pairs; insertion order preserved), 4 double (IEEE-754 bits as u64), 5 list (element count, then nested literals), 6 string (LPString), 7 wideint (signed 64-bit as u64), 8 wideuint (unsigned 64-bit), 9 lambda-bytecode (namespace FQN, arguments, compiled block, policy-controlled body source text) for use with apply, and 10 bytesrc (policy-controlled source text, namespace FQN, compiled block).
Source policy
Top policy 0 permits source-backed execution, 1 is strict compiled-only, and 2 is include-source. Saves write 1 by default or 2 with -include-source. Both nonzero policies execute PRECOMPILED material, and policy-zero format-93 artifacts remain readable.
AuxData families
jump tables (string or numeric), dictupdate, and NewForeachInfo.

LAMBDA SUPPORT

Literals in the script that represent lambdas for apply (lists of the form {args body ?ns?}) are compiled and serialized as lambda-bytecode literals at save time. A candidate is accepted only when its list value round-trips faithfully as a Tcl lambda — rebuilt the way the loader rebuilds it, including validation that the optional namespace element is absolute — so data lists that merely resemble lambdas remain ordinary data. On load, the compiled body, argument list, and optional namespace element are rehydrated into a Proc and registered in the ApplyShim so that the first call to apply does not trigger compilation. If type shimmer later evicts the lambdaExpr internal representation, the ApplyShim transparently re-installs it on the next apply call.

SEMANTICS AND PERFORMANCE

Artifacts are portable across little- and big-endian hosts. The loader detects host byte order once and decodes the little-endian wire fields explicitly. Numeric jump tables use Tcl’s one-word key representation on every host; a serialized numeric key that is not representable by a 32-bit evaluator is rejected instead of being truncated or treated as a string key.

The loader requires an exact major, minor, patch, and release-type Tcl producer match. An interpreter-neutral image preflight validates source policy, source fields, section totals and tags, opcode and instruction boundaries, literal/local/AuxData indices, direct and jump-table targets, exception boundaries, nested policy words, and trailing bytes before any interpreter-owned artifact material is constructed. Reconstruction uses one final packed ByteCode allocation and one copy of each instruction stream, without compile-environment staging arrays.

Loading evaluates the precompiled top-level in the caller’s current namespace with iPtr->scriptFile set to the authored source path (if recorded), then installs precompiled proc/method bodies and rehydrates lambda literals. The intent is to be functionally indistinguishable from source of the original script, with the benefit of faster startup due to avoided parsing/compilation.

PRECOMPILATION BOUNDARY

TBCX precompiles bodies and lambdas only when they are present in statically identifiable literal positions (script-body arguments to commands like foreach, while, try, eval, etc., or lambda literals for apply). Strings assembled at runtime — for example with format, string interpolation, or list construction — still round-trip correctly, but they remain ordinary data and compile at execution time when Tcl evaluates them.

OO SUPPORT

TBCX preserves normal TclOO class/object construction semantics by executing the rewritten top-level script, while substituting precompiled bodies for recognized oo::define / oo::objdefine method forms. Tested scenarios include class methods, self methods, per-object methods, private methods, inheritance (including diamond), mixins, filters, forwards, abstract/singleton metaclasses, method rename/delete/export changes, metaclasses with self method, and next-based constructor chaining.

Method visibility survives the round-trip however it was expressed: definition options (-export, -unexport, -private), lexical private { … } blocks (TIP #500 true-private), and same-body export/unexport (and self export/self unexport) commands are all folded into the per-method scope byte at save time and re-applied at load. Class-instance and per-object methods sharing the same name coexist (distinct origin keys), and per-object methods — including true-private ones — are installed with precompiled bodies. A method the saver left verbatim (non-literal name, arguments, or body) always keeps its authored body; precompiled records are matched positionally, in definition order, and patch only stub-sentinel bodies.

With -include-source, info class definition, info class constructor, info class destructor, and info object method all return the authored body text byte-for-byte, enabling introspection-based clone and copy idioms to work identically to the source-based baseline.

MULTI-INTERPRETER AND THREAD SUPPORT

TBCX follows Tcl’s standard threading model: only the thread that created an interpreter may call tbcx::save, tbcx::load, tbcx::dump, or tbcx::gc on that interpreter. Multi-thread support means multiple independent interpreters, each used by its owning thread — not sharing one interpreter across threads. Calling a TBCX command from a non-owning thread returns TCL_ERROR with a diagnostic message.

Artifacts are designed to load into interpreters other than the originating one. Statically recognized interp eval literal crossings are rejected with TBCX EVAL CROSSINTERP UNSUPPORTED; retained source is not compiled as a fallback. Interpreter-specific state (ApplyShim lambda registry, load depth, OO shim state) is consolidated in a single per-interpreter record and cleaned up automatically when the interpreter is deleted. Shared process-wide state — the save-side opcode dispatch table — is initialized exactly once, under a mutex, at package initialization, so concurrent tbcx::save calls from multiple threads are safe. Debug builds (or builds compiled with -DTBCX_THREAD_CHECKS) additionally assert interpreter-thread ownership inside internal helpers.

LIMITS

Sanity caps exist for code size (64 MiB), literal/AuxData/exception counts (1M each), string lengths (4 MiB), total serialized output (256 MB), serialization recursion depth (64), total WriteLiteral calls (2M), and total WriteCompiledBlock calls (256K). Every raw instruction and metadata reference is validated before ByteCode construction, and unexplained trailing bytes are rejected. Exceeding any limit produces an error.

Nested or reentrant tbcx::load calls are capped at depth 8 per interpreter to prevent runaway recursive loading.

DIAGNOSTICS

Representative messages include: “bad header”, “incompatible Tcl version”, “short read/write”, “unsupported AuxData kind”, “input is neither an open channel nor a readable file”, “runaway serialization detected”, “tbcx::save: unknown option …; expected -include-source”, “tbcx: called from non-owning thread”, and Tcl errors from top-level evaluation.

SECURITY

Loading executes code. Only load artifacts you trust.

Safe interpreters receive no tbcx::* commands by default; use interp alias or interp expose from a parent interpreter to grant selective access.

SEE ALSO

source(n), TclOO(n), info(n), apply(n), interp(n), tclcompiler and tbcload (Tcl 8.x bytecode tools)

© 2025–2026 Miguel Banon

MIT License.

↑ Top