Browse documentation

Bytecode VM

For contributors working on the bytecode executor and VM.

Executive Summary

  • Two execution modes — tree-walk interpreter (default) and bytecode VM (--mode=bytecode), sharing the same source pipeline, runtime objects, and GC
  • Executor abstraction — TGocciaBytecodeExecutor implements TGocciaExecutor and drives only the compiler and VM; two residual couplings remain — direct eval, and a module's top-level function declarations, which are created and run by the tree-walk evaluator
  • Goccia-owned VM — executes directly on TGocciaValue with tagged TGocciaRegister values; not a generic VM layer
  • Opcode space — core instructions (0-127) for hot paths, non-core generic arithmetic/bitwise and class helpers (128-145, with 146-166 unused), and semantic/helper instructions from 167 up: module and async orchestration plus later additions, including fused superinstructions
  • Binary format — .gbc files with little-endian encoding, GBC\0 magic, and version constant

Overview

GocciaScript has two execution modes:

  • Interpreter mode: tree-walk execution over the AST via TGocciaInterpreterExecutor
  • Bytecode mode: AST compilation to Goccia bytecode, then execution on TGocciaVM via TGocciaBytecodeExecutor

Both execution modes are implementations of TGocciaExecutor (see Architecture). The single TGocciaEngine class bootstraps the core language environment (global scope, core built-ins, shims) and delegates execution to whichever executor is configured. Optional runtime globals are attached through runtime extensions. The TGocciaBytecodeExecutor unit itself depends only on the compiler and VM; the VM it drives, however, still calls the tree-walk evaluator for direct eval (TGocciaVM.ExecuteDirectEval → EvaluateEvalProgram), so the bytecode path is not yet fully independent of the evaluator.

An imported module adds a second such coupling. Its environment is initialized while linking (ES2026 §16.2.1.7.3.1 InitializeEnvironment), and the module loader creates the top-level function declarations there with the tree-walk evaluator (HoistFunctionDeclarations). Bytecode compilation of that module therefore reuses those preinitialized bindings for exported declarations instead of compiling them (PreinitializedTopLevelFunctions), and their bodies keep running under the evaluator in bytecode mode. Everything an evaluator path can be handed from such a body — including a compiled TGocciaVMClassValue reached by new, super(), or a bound wrapper — must therefore work in both directions; TGocciaClassValue.UsesOwnInstantiation and TryConstructOnReceiver are what route construction of a compiled class back to the VM. scripts/differential/l-modulefndecl.test.js gates this split.

The program the executor runs itself — the entry file or a REPL input — is linked by no module loader. The compiler therefore records its static imports and re-exports, in source order, in the bytecode module's request table, and TGocciaBytecodeExecutor links each one through TGocciaModuleLoader.LinkModuleRequest before the program runs (ES2026 §16.2.1.6.1.2 Link()). A name that does not resolve is a SyntaxError before any module of the graph evaluates; the program's own OP_IMPORTs then evaluate the linked modules, re-exported ones included.

A module-source entry is also a Module Record of its own. Before its requests are linked, TGocciaEngine.RunModuleForSourceType registers it with the module loader under its resolved path, stamped with its file's modification time, and marks it evaluating, as Execute does for the interpreter. An import that resolves to the entry's own file — directly or through a cycle — therefore gets the entry back instead of evaluating the file a second time (ES2026 §16.2.1.6.1.3.1 InnerModuleEvaluation). The compiler records the entry's exports in the bytecode module's export table, and TGocciaBytecodeExecutor.InitializeEntryExports binds them after linking and before any import evaluates, as InitializeEnvironment (§16.2.1.7.3.1) does: a let, const, class or default-expression export is in its temporal dead zone until its declaration runs, a var export is undefined, and a function declaration — an anonymous export default function included — is hoisted and callable. Re-exports and exported imports bind to their linked modules. The program's OP_EXPORTs initialize and update the local bindings, so a module in a cycle with the entry reads them live. If the entry's run fails, the loader keeps its thrown value as the entry's evaluation error, so a later import throws it again (§16.2.1.6.1.3 Evaluate); an entry that fails before evaluating leaves the cache. A script-source entry is a Script, not a Module Record, so an import of its own file loads that file as a separate module.

Pipeline

Source -> JSX Transformer (optional) -> Lexer -> Parser -> Compiler -> Goccia Bytecode -> TGocciaVM -> TGocciaValue

Public bytecode artifacts use the .gbc extension.

Main Units

AreaUnits
Opcode definitionsGoccia.Bytecode.pas
Function templates / constantsGoccia.Bytecode.Chunk.pas
Module formatGoccia.Bytecode.Module.pas
Binary I/OGoccia.Bytecode.Binary.pas
Debug metadataGoccia.Bytecode.Debug.pas
VM executionGoccia.VM.pas
Frames / closures / upvaluesGoccia.VM.CallFrame.pas, Goccia.VM.Closure.pas, Goccia.VM.Upvalue.pas
Bytecode executorGoccia.Executor.Bytecode.pas (TGocciaBytecodeExecutor)
Numeric proof / loweringGoccia.Compiler.NumericProof.pas, Goccia.Compiler.NumericBindings.pas, Goccia.Compiler.Expressions.pas
Opcode name lookupGoccia.Bytecode.OpCodeNames.pas
ProfilerGoccia.Profiler.pas, Goccia.Profiler.Report.pas

Core Design

  • The register file uses tagged values that keep scalars unboxed until they cross a runtime boundary (see Design Direction).
  • The VM uses the same value classes as the interpreter: arrays, objects, classes, promises, functions, symbols, enums, and built-ins.
  • undefined, null, booleans, and hole values use shared singleton objects.
  • Sparse arrays use TGocciaHoleValue.HoleValue, not raw nil.
  • The VM is integrated with the shared garbage collector and shared call stack.
  • Call stack depth is tracked per frame (FFrameDepth) and enforced against a configurable limit of nested calls (CLI default 2 200, --max-stack=N). The outermost frame, the program's top level or a function the host calls while no script runs (a promise job, a test), is not a nested call and does not count. Exceeding the limit throws a RangeError: Maximum call stack size exceeded. Pass --max-stack=0 to disable the limit. The memory ceiling then bounds the recursion: the VM's stacks and the call records it pushes are charged to --max-memory and shrink back once idle, and a growth that does not fit is settled or refused with the same RangeError at the next instruction boundary. Garbage Collector § Memory Ceiling lists what is charged. Bytecode-to-bytecode calls use a trampoline (FFrameStack) so the Pascal call stack stays flat regardless of JS call depth.
  • A call the VM enters natively (ExecuteClosureRegistersInternal: constructors, async functions, generator resume, accessors, Proxy traps, callbacks from built-ins) is checked against the same limit, and against the native stack it costs: the VM throws the same RangeError once less than NATIVE_STACK_RESERVE of the thread's stack is left. See Embedding — Call Stack Depth Limit. A throw that leaves a native entry is raised after the dispatch loop's except block has ended, not inside it: on i386-win32 FPC runs an except block on top of the stack the exception was raised on, so a throw raised inside it would keep the stack of every entry it unwinds through until it is caught.
  • Type enforcement is opt-in in both execution modes. With --strict-types, the bytecode compiler marks annotated locals and parameters as strictly typed and emits OP_CHECK_TYPE wherever it cannot prove a value matches the annotation; without the flag, annotations are not checked. Return-type annotations are not enforced in either mode (#1276), so the compiler never takes a call's result type from one: arithmetic on the call uses the generic opcodes, and a binding initialized from it gets no inferred type. See Type Annotations.
  • Like the outermost frame, an imported module's top level is not a nested call. It runs above the frame of whatever evaluates it (the importer's top level, or a call that read a deferred namespace), and TGocciaVM.ExecuteImportedModule gives its frame that frame's depth instead of one more, so --max-stack counts only the calls live below it. Because the frame below can hold exception handlers at the same depth, an exception unwinding in a native VM entry matches only the handlers that entry installed.

Opcode Layout

The opcode space is split into three tiers:

  • 0..127: core VM instructions
  • 128..166: non-core generic arithmetic/bitwise operations
  • 167..255: semantic helper/orchestration operations

In the current VM (TGocciaOpCode in Goccia.Bytecode.pas, 214 opcodes, highest 236):

  • core instructions cover hot execution paths such as locals, typed arithmetic, comparisons, property/index access, calls, construction, iteration, and class/object setup; 99 is unused
  • the non-core range holds the generic arithmetic operations OP_ADD…OP_POW (129..134) and bitwise operations OP_BAND…OP_USHR (136..141), plus the class/object helpers OP_DEFINE_PROP_DYNAMIC (128), OP_DEFINE_CLASS_METHOD_DYNAMIC (142), OP_SET_CLASS_SOURCE_CONST (143), OP_CLASS_ADD_METHOD_DYNAMIC (144), and OP_SET_PRIVATE_CLASS (145); 135 and 146..166 are unused
  • the semantic range starts with module and async orchestration at 167 (IMPORT, EXPORT, AWAIT, IMPORT_META), and later additions were appended after it, including hot-path instructions such as OP_INC_NUMERIC (199), OP_GET_IMPORT_BINDING (213), OP_SUB_NUM_IMM (227), OP_CALL_SELF_NUM (229), and OP_JUMP_IF_NOT_LT (232)
  • IsValidGocciaOpCode rejects the unused numbers 99, 135 and 146..166

The current encoding helpers are defined in Goccia.Bytecode.pas:

  • EncodeABC
  • EncodeABx
  • EncodeAsBx
  • EncodeAx
  • DecodeOp
  • DecodeA
  • DecodeB
  • DecodeC
  • DecodeBx
  • DecodesBx
  • DecodeAx

Current instruction families:

  • load and move: constants, literals, locals, upvalues
  • control flow: jumps, handlers, throw, return
  • closures: function templates and captured upvalues
  • typed arithmetic and comparison
  • object and array operations
  • class construction and member definition
  • calls, construction, iteration, globals, and string coercion
  • opt-in compatibility scope helpers: unmapped arguments object creation and with object binding probes
  • semantic-only imports/exports, dynamic import, import.meta, await, and yield

Some opcode families intentionally use flags or mode operands instead of one opcode per syntax form. For example:

  • accessor definition uses constant-key and dynamic-key instructions plus getter/setter and static/instance flags
  • collection helpers use a shared opcode (OP_COLLECTION_OP) for object spread, object rest, and iterable-to-array spread
  • validation uses a shared opcode for require-object and require-iterable checks
  • generator metadata is serialized and a single OP_YIELD drives generator suspension; yield* uses the same opcode with delegation bookkeeping rather than a second opcode
  • bytecode generators suspend by snapshotting the VM continuation at OP_YIELD (instruction pointer, live registers, local cells, handler state, and GC-visible references) and resume from that snapshot instead of replaying from function entry

Current opcode design rules:

  • add explicit Goccia opcodes for stable, hot, language-owned behaviour
  • do not introduce generic VM naming into new instructions
  • do not add an opcode for something already reachable through existing call dispatch — if a built-in (e.g. Object.freeze) already goes through OP_CALL_METHOD, emit that call sequence from the compiler rather than adding a new opcode or sub-mode
  • prefer mode operands on shared opcodes over proliferating single-purpose opcodes for cold or infrequent operations

Performance Direction

Recent VM cleanup and optimization work has focused on reducing per-instruction overhead without reintroducing old abstraction layers:

  • cache and reuse shared primitive values directly in registers

  • avoid eager allocation of closure cells for uncaptured locals

  • pre-size argument collections for calls and construction

  • hold call arguments in a stack-disciplined arena window (FArgumentStack with a base+count window, mirroring the register and local-cell stacks) instead of a per-call dynamic array, so an ordinary call performs no argument-array allocation; frame save/restore and native re-entry store (base, count) rather than copying. A call instruction hands SetupNewFrame a pointer to its argument registers, and SetupNewFrame copies them into the callee's window before it acquires any register, because acquiring registers can move the register arena or, on a tail call, clear the slots the arguments sit in

  • defer stack-trace frames on the hot call path: push the function-template pointer rather than copying its name/source strings, and materialise them only when a trace is captured (see ADR 0074)

  • keep TGocciaExecutionContext unmanaged on Push/Pop: intern source paths as pointers instead of copying a UnicodeString on every call (see ADR 0114)

  • resolve thread-bound state once per native entry, not per call: the call stack and the execution-context stack live behind thread variables, and every reference to one is a thread-local lookup. When native code enters a VM that is running nothing, BindToCurrentThread caches the thread's TGocciaCallStack, a handle to its context stack and the address of its GThreadPolls word (see Thread Variables on Hot Paths) on the VM; the dispatch loop tests FThreadPolls^.TimeoutArmed before it calls CheckExecutionTimeout, and reaches the call stack around a native call through FCallStack; and every bytecode call inside that entry pushes and pops through them (PushFunctionContext / PopFunctionContext, which also switch the realm through the address of the current-realm variable). It also clears FNativeStackLimit, the native stack limit of the thread, which the first native entry nested more than NATIVE_REENTRY_UNCHECKED_DEPTH deep looks up and keeps. Nested entries reuse the binding, because they run inside the outermost one on the same thread

  • keep SetupNewFrame free of managed locals: the callee's source path is never copied into a string local, and the reference interned for the execution context is reused while consecutive calls run code from the same source file (InternExecutionSourcePath handles a different path, out of line)

  • decide whether a callee is a bytecode function with one exact class comparison in OP_CALL and OP_CALL_METHOD: TGocciaBytecodeFunctionValue is sealed and a sibling of the native and bound function classes, so a bytecode callee skips the is tests for those and any other callee skips the one for a bytecode function, each of which would walk a whole class chain to fail

  • convert the arguments of a native call into bytecode (TGocciaVM.ExecuteClosure: a callback, a comparator, a getter) in a stack buffer when there are at most eight, instead of in a heap array

  • execute compiler-proven closed-world numeric self-calls through OP_CALL_SELF_NUM: recursive calls with one to three scalar arguments use a compact register frame while sharing the generic entry frame's closure, lexical environment, local-cell and argument windows, realm, and execution context (see ADR 0101)

  • use unchecked template access in the dispatch loop where bounds are already guaranteed

  • fuse Number - Int16 as OP_SUB_NUM_IMM and conditional Number <= Int16 as OP_JUMP_IF_NUM_NOT_LTE_IMM only when the compiler proves the source is an ECMAScript Number; these instructions remove literal-load and branch dispatches rather than merely replacing a generic arithmetic dispatch

  • fuse for/if/conditional A < B as OP_JUMP_IF_NOT_LT so the compare and JUMP_IF_FALSE share one dispatch; the opcode keeps generic < semantics (Number, BigInt, ToPrimitive/valueOf) and is not a Number-only shortcut

  • fuse local.ident as OP_GET_LOCAL_PROP_CONST: one instruction that reads the local slot (including the OP_GET_LOCAL TDZ hole check) and then the existing OP_GET_PROP_CONST shape-lite IC; computed keys, optional chaining, with lookups, import bindings, and global-backed identifiers keep the unfused path

  • retain a static named import's linked module namespace in its local/upvalue slot: OP_IMPORT scales with declarations, while repeated identifier reads use OP_GET_IMPORT_BINDING to dereference the cached live binding identity without repeating module-loader lookup. A global-backed script, whose environment no link step fills, also predeclares each imported name in the global scope and binds it there (OP_CREATE_GLOBAL_IMPORT_BINDING for a named import, a const define for a namespace, source-phase or deferred binding), so a later script against the same scope, such as the next REPL input, reads the live binding

  • read an initialized local straight from its register when it is an operand of a binary operator, a comparison jump, a compound assignment, an element access or a store, instead of copying it into a temporary with OP_GET_LOCAL first. The copy does three things, and the compiler takes the direct read only where it can show that none is needed (TryResolveSettledLocalName in Goccia.Compiler.Expressions.pas):

    • The TDZ check. A local is marked once the code that initializes it has been compiled: its declaration, a for...of or counted-loop binding, or the parameter preamble. The mark is forgotten at each switch clause, because a clause can be entered without running an earlier clause's declarations. An assignment to a marked local also drops its TDZ probe.
    • Reading the cell of a captured binding. A closure writes a captured local through its cell and leaves the register stale. A const cannot be written. A let binding or a parameter is read directly only while no closure compiled so far has captured it and the read is not inside a loop that creates a closure anywhere in its body (StatementCreatesNoClosure in Goccia.Compiler.OperandSafety.pas examines a loop before it is compiled, because a closure created later in a loop runs before an earlier read on the next iteration; a loop nested in a flagged one inherits the answer, and every other loop is examined itself, since a finally block inlined at a return or break is compiled inside a loop it does not belong to). Parameters of a function with an arguments object keep the copy, because a mapped arguments object writes parameter cells; so does a function from its first direct eval onwards. A host that offers direct eval at all (the Test262 host, and a ShadowRealm created from such a realm) marks its realm with HostsDirectEval when it installs the function, and the executor passes that to the compiler as DirectEvalAvailable, which keeps the copy for every let binding and parameter: a closure created by direct eval writes registers by slot in whichever function calls it.
    • Taking the value before later operands run. a + (a = 2) adds the old value, so a let binding or a parameter is read directly only when no operand evaluated after it can write a local (ExpressionKeepsLocalRegisters): no assignment, increment, destructuring assignment, direct eval, yield or await. A call is allowed, since the callee has its own registers and no closure over the binding exists.

    Imports, global-backed bindings, var bindings and destructured parameters keep OP_GET_LOCAL, and so does every read when coverage is enabled, because a source line holding only such an operand would otherwise emit no instruction and be reported as never executed

  • compile a parameter's default initializer straight into the parameter's register, with no move, when nothing evaluated up to the end of that initializer can reach the parameter's binding (ParameterInitializerAvoidsOwnBinding in Goccia.Compiler.OperandSafety.pas): no identifier, assignment or destructuring target in it or in an earlier parameter names the parameter, none calls direct eval, and no arrow or function created there has the name in its source text. An initializer compiled into the register writes intermediate values there (an array or object under construction, the left operand of &&, an assigned value before the target's TDZ probe), which would replace the hole that a read or write of the parameter in its own initializer must find. An initializer that can reach the binding is therefore compiled into a temporary and moved into the register once it completes

  • skip the result move of a property or element store whose value is discarded, as in the statement list[index] = value;

  • decide === and !== on the two registers (VMStrictEqualRegisters) wherever the register kinds, one pointer compare and IsPrimitive settle the answer: two scalars, an object against itself or another object, an object against a scalar. A register does not hold a value in one canonical form (NaN and the infinities can be doubles or allocated numbers, and some built-ins return a boolean that is not the shared true/false), so a primitive held in an object register, a number object compared with itself and a hole still go to IsStrictEqual

  • read standalone this properties directly from a non-captured local register, preserving the derived-constructor guard while avoiding a temporary-register move; captured, top-level, and method-call receiver paths retain their existing lowering

  • keep fast register access limited to proven hot/simple paths; local-slot and complex property paths should only move to fast access when they stay correct and measurably improve throughput

  • the register window fill is GC-safety/correctness critical (the GC marks the whole live window, and a callee reads those slots before it writes them): it is deliberately retained rather than trimmed. A local-cell window must likewise hold no cell, but most frames capture no local and create none, so the VM keeps a mark above which every slot of the cell arena is nil (FLocalCellStaleTop, raised by whatever stores a cell) and clears only the part of a new window below it. Development builds assert that each new or grown window is clear; that is a local check, since CI builds with production flags and assertions off, and what fails there for a broken mark is the suite's closure and generator tests. The argument window is not cleared, because SetupNewFrame stores every slot of it before anything that can collect runs (see ADR 0126 for both invariants, the thread binding, and the sealed callee class below)

Growing String Accumulators

Primitive string additions and OP_CONCAT can retain an immutable prefix instead of copying it on every append. Prefixes shorter than 256 UTF-16 code units stay flat; a deferred chain has at most 32 links. Reading the native TGocciaStringLiteralValue.Value property materializes the chain, and reaching the depth limit materializes the prefix before appending. Earlier aliases keep their original contents. Object coercions retain the existing ToPrimitive ordering and rooted arithmetic path.

Materialization never collects or calls guest code: append creation reserves the eventual flat buffer in advance. This preserves the existing rooting contract of primitive comparisons and other string reads. The representation reduces copying, while its reserved capacity still counts against the memory limit. See ADR 0116 for the measurements and GC accounting for the reservation lifetime.

Inline Caches

Four per-site inline caches live on TGocciaFunctionTemplate, all indexed by the instruction's name-constant index, all runtime-only (never serialised to .gbc):

  • Global reads (OP_GET_GLOBAL) — TGocciaGlobalReadCacheEntry validates (scope identity, binding-map entry version) and re-reads the binding by entry index, skipping the name hash. A hit on an initialized lexical binding reads its stored value in place; a binding still in its temporal dead zone, or a built-in backed by the global object, continues on a path that knows the binding's name. An import binding is never given a cache entry and creating one re-stamps the entry version, so reads of it always resolve through the exporting module.
  • Own property reads (OP_GET_PROP_CONST, OP_GET_LOCAL_PROP_CONST) — TGocciaPropertyReadCacheEntry validates the receiver's interned shape (Goccia.Values.Shape): same shape implies the same key at the cached entry index, so one site hits across many same-layout receivers. The descriptor kind is re-checked on every hit because data-to-accessor redefinition keeps the entry index.
  • Prototype-resolved reads (OP_GET_PROP_CONST, OP_GET_LOCAL_PROP_CONST, after an own miss) — TGocciaProtoReadCacheEntry proves continued absence of the name on the receiver and intermediate levels and presence at the holder, all by shape identity per level, then re-reads the holder descriptor by entry index. Each level is first compared against the shape its map last computed, which is a field read: at the holder a match is enough, because a shaped map only appends and the cached entry stays where it was; at an absence level the shape must also be as deep as the map has entries (or the map has neither a shape nor entries), so that it describes every entry and EnsureShape would have nothing to add to it. Only a level that fails that comparison calls EnsureShape, so a hit on receivers whose shapes are current makes no call and reads no thread-local. The live chain is re-walked per hit, so setPrototypeOf is followed inherently; chain levels must be exact TGocciaObjectValue; chains deeper than two levels and accessor holders stay generic. Class instance methods (data properties on the class prototype object) are the dominant beneficiary.
  • Own property writes (OP_SET_PROP_CONST) — TGocciaPropertyWriteCacheEntry is the write-side counterpart of the own-read cache: same (shape, entry index) validation, storing only own writable data properties. Writable and the descriptor kind are re-checked on every hit. Accessors, proxies, private fields, deletion, prototype mutation, non-writable descriptors, and non-ordinary receivers take AssignProperty. This cache is independent of the read/proto slot map so the read-side PIC is not expanded (ADR 0088).

Hits and fills serve only exact-class TGocciaObjectValue / TGocciaVMLiteralObjectValue / TGocciaInstanceValue receivers, so overridden lookup semantics (proxies, exotic objects, private names) always take the generic path. OP_GET_PROP_CONST tests those three classes before anything else and sends every other receiver to GetPropertyValue; OP_SET_PROP_CONST uses the same test to skip its class-value check. A read or a store that hits its cache therefore runs no is test at all. The slot accessors (PropertyReadCacheSlot, ProtoReadCacheSlot, PropertyWriteCacheSlot) are inlined into the dispatch loop and call out only to assign a slot on a constant's first use. Shapes are computed lazily at fill time (EnsureShape), not eagerly at property-append time: a stale shape is a true prefix description of an append-only layout, so the hit path may read it raw and at worst misses. Delete/clear flip a map to dictionary mode (a sentinel shape that never matches a cache entry). A map also flips to dictionary mode when EnsureShape runs from a non-owner realm, so cross-realm property reads never intern one realm's layout into another realm's shape table. A cache hit does not run EnsureShape and so does not flip the map. That is sound: a hit needs the cache entry to hold a shape from the map's own realm, which only a fill made while that realm was current can have put there, or it needs an empty map at a level the entry recorded as empty, and an empty map has no layout to confuse. After PROPERTY_READ_CACHE_POLYMORPHIC_LIMIT (reads) or PROPERTY_WRITE_CACHE_POLYMORPHIC_LIMIT (writes) consecutive misses-with-refill or fill declines a site is megamorphic: it stops probing and serves gated receivers through the uncached own-data fast path.

Cached pointers (scope, shape) are compared for identity only and never dereferenced. Scope cache entries carry an entry-version stamp against allocator address reuse; shape entries need none, because shapes are never freed within an engine's lifetime, function templates never outlive their engine, and cross-realm maps stop shape tracking before a foreign realm can cache their owner layout.

Computed property access (OP_ARRAY_GET/OP_ARRAY_SET, OP_GET_INDEX/OP_SET_INDEX, OP_DEL_INDEX) shares one key-classification and receiver-dispatch implementation (ClassifyPropertyKey plus the ExecGet/ExecSet/ExecDeleteComputedProperty cores in Goccia.VM.pas); per-opcode semantic differences are explicit TGocciaComputedAccessOptions, not divergent copies. The get and set cores are ExecGetComputedPropertyGeneric and ExecSetComputedPropertyGeneric; ExecGetComputedProperty and ExecSetComputedProperty reach them only after trying the element accesses that need no property key, because the cores hold managed locals. A dense TGocciaArrayValue element at an integer-register index is read there directly, and OP_ARRAY_SET overwrites one through TrySetDenseElementFast, which declines when the array holds any descriptor, is frozen, or has a hole at that index; a hole, an out-of-range index, or any other key takes the core. A non-BigInt TGocciaTypedArrayValue receiver at an array-index key takes an unboxed element fast path (TryReadIndexedScalar/TryWriteIndexedScalar): reads move the element straight into a register scalar and numeric-scalar writes store it directly, so neither allocates the heap TGocciaNumberLiteralValue or index-name string the generic object branch would. Mutable fixed-length ArrayBuffer views additionally retain their constructor-proven data block and length, checking only detachment and the element index on each access; resizable, shared, and immutable backing buffers retain the general synchronization path. BigInt kinds, non-index keys, and non-scalar write values fall through to the boxed path; an out-of-range or detached read does too (yielding undefined). A non-BigInt scalar write, however, keeps its integer-indexed exotic semantics in place even for an out-of-range index or immutable backing buffer — the store is skipped and reported as successful, never boxed. All value semantics are preserved, including the observable ToNumber ordering of integer-indexed [[Set]].

The current optimization target is reducing bytecode-mode suite time further without diverging interpreter and bytecode semantics.

Numeric call-derived proof is deliberately closed-world. It applies only to a simple, const-bound local arrow with no escape, optional call, default/rest/ destructured parameter, mixed argument, or unsupported use. Every external direct call must supply exactly one numeric literal per parameter, and the expression body must establish a Number result recursively. The proof marks parameters for the two numeric immediate superinstructions; for one to three parameters it also emits OP_CALL_SELF_NUM at non-tail direct self-call sites. The scalar frame performs no speculative type conversion or deoptimization: unsupported shapes retain ordinary OP_CALL, and tail calls retain the generic proper-tail-call path. The optimization does not turn the function into a generally typed function or change generic + semantics.

A scalar frame takes its register window without clearing it, so the window can still hold references that an earlier frame left there, to objects that may since have been freed. The collector therefore does not mark scalar frames: when any is live, the bytecode stack root marks the register arena only up to the first scalar frame's window. That loses nothing, because a scalar frame writes each register before reading it and stores only scalars and the pinned NaN, infinity and -0 values. It relies on scalar frames being the innermost frames whenever one is live, which holds because they call only themselves; development builds assert that no frame is set up while a scalar frame is live (see ADR 0127).

Profiling

The --profile option on GocciaRunner enables language-level profiling of the bytecode VM. See profiling.md for the full guide.

  • --profile=opcodes — opcode frequency histogram, opcode pair frequency (superinstruction candidates), and scalar fast-path hit rate for generic arithmetic/comparison opcodes
  • --profile=functions — per-function self-time, total-time, call count, and heap allocation count
  • --profile=all — both
  • --profile-output=path.json — JSON export

The profiler follows the same singleton-tracker pattern as coverage (Goccia.Coverage.pas). ExecuteClosureRegistersInternal selects a production dispatch loop when coverage, opcode profiling, stop-IP, and the instruction limit are all inactive; that loop omits those per-instruction checks. Any of them being active selects the instrumented loop, which keeps the previous guards. Enabled-mode overhead depends on the workload and profiling mode, so measure it on the corpus being investigated rather than relying on a fixed percentage.

Runtime Error Diagnostics

A runtime fault must read identically in both execution modes. This machinery keeps that true:

  • Call-site descriptors. TGocciaFunctionTemplate carries a runtime-only table mapping a call/construct instruction's start PC to the callee as the author wrote it (a TGocciaCalleeDescriptor from Goccia.Error.CallDiagnostics) plus the call expression's own line and column. The compiler fills it while emitting OP_CALL, OP_CALL_METHOD, OP_CONSTRUCT, OP_CONSTRUCT_SPREAD and tagged-template calls — a compile-time cost paid once per call site (a descriptor copy plus a whitespace-normalising pass over the callee's source text), not a per-instruction runtime cost. The VM reads the table only when the callee turns out not to be callable or constructable, so obj.missingMethod() reports obj.missingMethod is not a function rather than undefined is not a function. The evaluator derives the same descriptor straight from the AST, and both modes format the message and suggestion through the same functions. The table is not serialised to .gbc: a module loaded from binary bytecode falls back to the runtime-type-name form of the message (see the note below).
  • Binding names. A temporal-dead-zone ReferenceError reads Cannot access 'x' before initialization and a const assignment reads Assignment to constant variable 'x', as in the evaluator. The compiler records each local's name, slot and live PC range in the template's debug locals (TGocciaCompilerScope.AttachTemplate, closed at EndScope), and upvalue descriptors already carry names; the VM's hole checks look the name up only once they are about to throw (ThrowUninitializedLocal, ThrowUninitializedUpvalue), so a read of an initialized binding does no extra work. The const-assignment message is a constant compiled into the throwing instruction. An assignment to a const first checks the binding with OP_CHECK_BINDING_INITIALIZED, so a const still in its dead zone throws the ReferenceError rather than the TypeError (ES2026 §9.1.1.1.5 step 3). That opcode checks the captured cell or global binding the assignment resolved before its right-hand side ran, never a same-named var a direct eval in the right-hand side declared. Debug locals are serialised to .gbc in the section the format already had; bytecode without them, such as a .gbc from an earlier build, falls back to the unnamed Cannot access lexical binding before initialization.
  • Throw-path source positions. Deferred call frames carry no position (ADR 0074), which left every bytecode-mode stack frame at file:0:0 and the runner with no line to render a code frame for. TGocciaCallStack.SetTopFrameLocation stamps the executing frame with a position. The dispatch loop's ordinary call/property/index instructions never call it, so their throughput is unchanged; it runs on the failure paths (a fault about to be raised) and, in addition, once per new reaching ConstructValue — a native constructor such as new Error(...) captures its trace during construction, before any throw, so its caller frame must be stamped up front (a small per-new cost: a binary search over the function's call-site table plus two string assignments). TGocciaVM.StampThrowLocation recovers the current instruction for throw paths outside the dispatch loop through a pointer probe into the innermost loop's Template/InstructionStartIP locals, saved and restored once per native re-entry.
  • Caller frame positions are worked out at capture. A frame nothing has stamped reports where it is executing now, worked out only when a trace is captured (ADR 0131). Each native entry into the dispatch loop records a TGocciaVMActivation: its first call-stack frame, its first frame-stack and closed-numeric-frame slots, and its probes. Each frame the entry runs pushed one call-stack frame. For every frame but the executing one, the VM saved an instruction pointer when that frame made its call, so the two sequences pair up in order. TGocciaVM.ResolveFrameLocations steps back from each saved pointer to the call instruction and looks it up in the call-site table, falling back to the line map. The executing frame is located the same way from its Frame.IP probe. The VM installs this resolver on the thread's TGocciaCallStack for as long as it runs. A call does no position work at all; a native entry stores one activation record.
  • Property reads have their own line-map entry. An expression's line-map entry precedes the code of its operands, so the load of a.b.c would be located at whatever operand the compiler emitted last. EmitLoadMemberProperty gives the load instruction the member's position (its property name, or the [ of a computed read) and hands the instructions after it back to the entry they had, through BeginInstructionLocation/EndInstructionLocation. Only the line map changes; the instructions are the same. An entry at the same PC as the entry before it replaces that entry, which a lookup could never reach (TGocciaDebugInfo.AddLineMapping); the first entry always stays. Where each position points is in Errors — Stack Traces.
  • Frame source is provenance-bound, not stack-selected. A code frame is rendered only from provenance the engine records on a genuine error when it is created — the top call frame's source location, plus a ±context excerpt of that module captured from the engine's own per-scope source store. Both are carried on the error object (TGocciaErrorObjectValue, an error-only subclass so the fields never enlarge the base object's GC-charged size). The thrown value's stack string is guest-writable and is never parsed to choose a file, a line, or an excerpt: a forged throw { stack: "…at f (/etc/passwd:2:1)" } object is not engine-created, carries no provenance, and renders no code frame at all — so no host ever opens a guest-named path and the sandbox's sandbox.fs.path gate cannot be bypassed through diagnostics. See Goccia.Values.ErrorHelper.AttachErrorSourceProvenance, Goccia.Diagnostics.SourceRegistry, and docs/module-resolution.md "Runtime code frames".
  • Principal exclusion — no host source to a guest. Ownership is decided at load, not inferred at capture. Host enrollment (--globals, --host-environment, --module, --modules, manifests/configs, and embedding injection) stamps the root module host-owned. Static, dynamic, and deferred imports inherit that ownership transitively from the importing module, even when the load begins later from a host-exported function called by the guest. A host-injected virtual module is host-owned; imports reached only from a guest module are guest-owned. Entries use file identity from the handle that read the content: POSIX device+inode or Windows volume-serial+file-index. Symlinks, junctions, hardlinks, and case aliases therefore cannot mint a guest-owned second copy. If identity lookup fails, there is no lexical-path fallback and source disclosure for the scope fails closed. An excerpt is captured only from guest-owned source, so a genuine error thrown inside a host module — including a transitive import, a --module the guest imported, or a held Error the host created — shows its location but no source excerpt.
  • Executing-engine scope, enforced again at render. Each engine's module loader owns one scope, identified by a durable process-monotonic principal; registration targets the loader's own scope, and capture targets the scope the engine activates around its execution (RunModuleForSourceType for bytecode, Execute/ExecuteProgram for the interpreter) — and around every cross-engine transition (ActivateRealmExecutionContext for a ShadowRealm evaluate/ importValue/wrapped function activates the child scope and restores it on return). The excerpt is additionally stamped with its principal and re-checked when rendered: each host explicitly passes its expected principal to Goccia.Error.Detail, which renders source only when the two values match. Zero/no supplied principal means location-only; no active execution scope is never treated as authorization. A child's error formatted by a resumed parent is therefore refused the child's source even though the object crossed the boundary. Because the excerpt is captured while its scope is live, a host may still render it after Engine.Free only if it retained and supplies the originating engine's principal.
  • Byte-accurate diagnostic memory. Retained module source is accounted as its actual UTF-16 line representation, including entry/list storage, pointer slots, and separately allocated line strings. A captured excerpt is charged as its retained UTF-16 string allocation. The identical byte figure is used for caps, --max-memory reservation, and release. Reservation refusal yields location-only, and transactional registry insertion rolls back partial map keys and the reservation if allocation fails during commit.
  • Construct/native-call frame stamping. A successful OP_CONSTRUCT / OP_CONSTRUCT_SPREAD snapshots and restores the caller frame around ConstructValue, so a constructor's position does not leak onto a later throw (new Map(); JSON.parse("{") reports the JSON fault at its own line, not the new). Native calls stamp the call site around the invoke, likewise restored, so a native callee's error carries a location rather than 0:0. A throw skips those restores, so HandleExceptionUnwind clears the position of the frame whose handler it lands in (TGocciaCallStack.ClearTopFrameLocation): the stamp of the call the throw abandoned, or of the fault that raised it, does not locate a later error in that frame. The work stays on the throw path, so a call that returns pays nothing for it.

.gbc parity note

The call-site descriptor table is intentionally runtime-only, so a module executed straight from a compiled .gbc (rather than compiled in-process from source) has no descriptors: its non-callable/non-constructor faults fall back to the runtime-type-name form (undefined is not a function) and carry no suggestion, whereas an in-process compile would name the callee. This is the one diagnostic-parity gap left open. Serialising the table would close it at the cost of a new bytecode-format section (callee text, object/property text, kind, line, column per call site) and its verifier; that is a bounded but real format change, deferred rather than taken here because the .gbc path is not on the default runner or test surface. Revisit if binary modules become a primary execution path.

Instruction Limit

The dispatch loop supports an optional instruction counter (Goccia.InstructionLimit.pas). When armed, execution uses the instrumented loop: the counter increments on every dispatched instruction and the limit is checked at the top of each iteration. When the budget is inactive, production dispatch omits that poll entirely. See Embedding — Execution Limits for the full API and interpreter-mode behavior.

Binary Format

  • Magic: GBC\0
  • Version constant: GOCCIA_FORMAT_VERSION
  • Endianness: little-endian
  • File extension: .gbc

Binary loading is a validation boundary. The reader rejects oversized files, declared strings or tables that exceed the remaining stream, excessive nested function templates, invalid enum tags, and trailing data. Before a module can execute, the verifier checks opcodes, register destinations, constant and function references, control-flow targets, and exception-handler metadata. The VM retains bounds checks on instruction, constant, and function access as defense in depth.

A loaded file carries no proof, so the verifier makes a loaded OP_CALL_SELF_NUM memory-safe on its own. The closed numeric frame it would enter is sound only under the compiler's numeric-only proof, which is not serialized (ADR 0101), and the collector does not mark that frame's registers (ADR 0127), so a crafted file could otherwise park an object in the unmarked window and have the collector reclaim it. The verifier rewrites a loaded OP_CALL_SELF_NUM in a synchronous arrow to OP_CALL_SELF, the ordinary self-call whose frame the collector marks, and rejects the opcode in any other template kind. Code compiled in process keeps the fast path; only loaded templates are rewritten. OP_CALL_SELF is runtime-only: it is reserved at 255, the top of the opcode range, outside TGocciaOpCode, so the loader rejects it in a file, and file opcodes must stay below it.

Current Status

  • --mode=bytecode runs the Goccia VM directly.
  • The full JavaScript suite passes in bytecode mode.
  • The old generic VM/runtime bridge has been removed from the active build.

Design Rationale

GocciaScript includes a bytecode executor built specifically for GocciaScript. The current VM is not a language-agnostic subsystem: it executes directly on TGocciaValue, shares the same runtime objects as the interpreter, and uses a Goccia-owned opcode surface.

Why a Bytecode VM?

The tree-walk interpreter directly evaluates AST nodes via recursive function calls. This is simple and debuggable, but carries overhead from VMT dispatch on every AST node, deep call stacks for nested expressions, and no opportunity for instruction-level optimization. A bytecode VM trades compilation cost for faster execution: flat instruction dispatch, register-based operands, and a compact in-memory representation.

Why Register-Based?

Stack-based VMs (like the JVM and WASM) are simpler to compile to and have smaller instruction encoding. Register-based VMs (like Lua 5, LuaJIT, and Dalvik) need fewer instructions per operation and avoid redundant stack manipulations. Register-based was chosen for execution performance.

Why Three Tiers?

The solution is a split opcode space with three tiers:

  • Core range (0–127): register, control-flow, closure, literal, and other hot/stable VM operations.
  • Non-core generic range (128–166): generic arithmetic and bitwise operations that are still explicit bytecode but handle mixed or untyped operands, plus a few class/object definition helpers.
  • Semantic helper range (167–255): language-level orchestration operations such as imports/exports, dynamic import, import.meta, await, and resource disposal, plus opcodes added later — including fused superinstructions on hot paths — so the range is no longer only cold code.

This split keeps the dispatch surface organized while still allowing the bytecode executor to be explicitly Goccia-specific.

Why Shared Runtime Values?

The VM shares the TGocciaValue object model with the interpreter rather than maintaining a second value representation. Registers use TGocciaRegister — a tagged variant record (Goccia.VM.Registers.pas) that keeps booleans, integers, and floats unboxed as scalars. Values only cross into TGocciaValue when they leave the register file (e.g., property access, function calls, GC marking).

That choice removes:

  • conversion layers between interpreter values and VM values
  • duplicate object models for arrays, objects, classes, and promises
  • bridge-only GC root management
  • bytecode/runtime disagreement over undefined, null, and sparse array holes

The trade-off is that arithmetic fast paths are split between scalar register operations (typed opcodes like OP_ADD_INT / OP_ADD_FLOAT) and generic TGocciaValue fallbacks (like OP_ADD).

Compiler-Side Desugaring

Language features are compiled into compact bytecode instruction sequences rather than expanding the opcode surface unnecessarily:

  • Nullish coalescing (??) and nullish coalescing assignment (??=) — The compiler emits OP_JUMP_IF_NOT_NULLISH in its nullish-match mode, so undefined, null, and internal hole values all follow the same short-circuit path without extra comparison instructions.
  • Template literals — The compiler parses interpolations at compile time, emits string constants and OP_TO_STRING for expression parts, then chains OP_CONCAT instructions.
  • Object literals — Data properties compile to OP_DEFINE_DATA_PROP so object initializers create or overwrite own enumerable data properties. Concise methods use OP_DEFINE_METHOD_PROP to attach [[HomeObject]] without changing plain data-property function or arrow values. Ordinary property assignment still uses the OP_SET_* family and keeps [[Set]] prototype-chain semantics.
  • Object spread — The compiler emits dedicated Goccia bytecode rather than routing through a generic extension dispatcher.
  • Increment/decrement (++/--) — The compiler emits fused numeric update opcodes for increment/decrement sites. Prefix or discarded-result sites use OP_INC_NUMERIC/OP_DEC_NUMERIC; postfix sites with distinct result and storage registers use OP_POST_INC_NUMERIC/OP_POST_DEC_NUMERIC so the old numeric value is produced while the binding/property value is updated. A postfix update whose result belongs in the updated local's own register, as in (a = a++) => a or var x = x++, produces it in a temporary and moves it into the local after the store. All variants preserve BigInt, convert other inputs through ToNumeric, and keep the read/convert/write side effects required by ES2026 §13.4.4.1. They operate on a local's own register without a preceding OP_GET_LOCAL, so they perform its TDZ check themselves: a register still holding the hole throws ReferenceError. That check sits on the non-numeric path, so an update of a number costs nothing extra. An update of a const reads and converts its operand in the same way, on a temporary register, and throws the assignment TypeError where the store would be.
  • Traditional for lexical bindings — let/const loop initializers keep the full per-iteration environment path whenever closures, direct eval, suspension, destructuring, pattern matching, with, using, or nested declaration boundaries can observe binding identity. Plain generated/counting loops share the loop lexical scope and avoid the otherwise redundant copy-in/copy-out sequence. Each copy of a binding into the next iteration's environment (ES2026 §14.7.4.4 CreatePerIterationEnvironment) reads the binding's last value: where a closure may have captured the binding, which may have written its cell and left the register stale, the copy uses OP_GET_LOCAL, and otherwise it stays a plain OP_MOVE. That covers closures created in the initializer, the test, the body and the update, and the counted-loop fast path.

This keeps the emitted bytecode compact and makes opcode additions deliberate instead of reactive.

Compatibility Scope Helpers

Compatibility features that alter identifier lookup still compile to explicit VM state instead of falling back to interpreter behavior.

  • arguments object — With --compat-arguments-object enabled, function templates snapshot the current call arguments in the frame. --compat-non-strict-mode does not enable this helper by itself. OP_CREATE_ARGUMENTS materializes the object into the declared local slot before parameter defaults and body execution, so default initializers can observe arguments.length and generators see the original call list after suspension/resume. Operand B selects mapped semantics for sloppy simple parameter lists and operand C carries the formal parameter count; the VM forces those parameter locals into cells so indexed properties alias parameter bindings even if the object escapes. Strict functions, modules, and non-simple parameter lists use unmapped arguments objects.
  • Function-level var bindings — With --compat-var, the compiler hoists a function body's vars (and, in non-strict code, a block function's Annex B var binding) into registers, and the body starts with one OP_LOAD_UNDEFINED per hoisted var (ES2026 §10.2.11 step 29.c.i.3). A new register window is cleared, but the call copies every argument into the registers after this, so arguments past the last parameter land in the first vars' registers, and destructuring a parameter uses the registers above the parameters as temporaries. A var named like a parameter keeps the parameter's value, and the name of a body function declaration is skipped because its function object is stored there before the body runs. A body without a var emits nothing extra.
  • Non-strict this binding — Function templates serialize their strict-this mode. With --compat-non-strict-mode enabled for script source, ordinary function templates clear it so VM call paths coerce nullish this to globalThis; arrows and class methods keep their existing lexical or strict receiver behavior. Module source ignores the compatibility flag for this decision.
  • Non-strict assignment — Failed object/global writes throw by default. In script source non-strict compatibility mode, the compiler emits OP_SET_PROP_CONST_LOOSE, OP_SET_INDEX_LOOSE, and OP_SET_GLOBAL_LOOSE for ordinary writes so failed [[Set]] results are ignored while null/undefined property access and throwing setters still raise errors.
  • with statement — With --compat-non-strict-mode enabled for script source, the compiler lowers with (expr) body to OP_TO_OBJECT, stores the object in a hidden local, and records that hidden binding in the compiler scope. Identifier reads, writes, updates, and identifier calls inside the dynamic extent emit OP_HAS_WITH_BINDING probes from innermost to outermost hidden object before falling back to normal local/upvalue/global resolution. Writes that resolve to a with object use the loose set opcodes in non-strict mode. Nested functions inherit the hidden binding as an upvalue when captured, preserving closures created inside with.
  • Non-strict delete — With --compat-non-strict-mode enabled for script source, member deletes emit OP_DELETE_PROP_CONST_LOOSE or OP_DEL_INDEX_LOOSE, which preserve strict null/undefined errors but return false for non-configurable properties. Identifier deletes compile to local/upvalue false results, OP_DELETE_GLOBAL for global object property semantics, or with binding probes as needed.

Compiler Optimizer

Bytecode compilation includes a small compile-time value optimizer. It folds pure primitive constant expressions, propagates immutable const bindings initialized from compile-time constants, and omits branches or statement tails that are provably unreachable.

The optimizer is intentionally compiler-side only:

  • it does not add opcodes or change the .gbc format
  • it does not track mutable bindings, imports, destructuring, or function/class declarations
  • it only uses --strict-types for conservative algebraic simplifications where the strict type alone preserves JavaScript semantics

A top-level const of a script or an imported module lives in the global or module environment rather than in a register, so that other code can reach it by name. Its value is propagated only to reads compiled after the declaration. Top-level statements run in source order and hoisted function declarations are compiled before all of them, so no such read can run while the binding is in its temporal dead zone; a read compiled earlier keeps OP_GET_GLOBAL and throws ReferenceError there. The binding is still declared, defined and exported, so direct eval, later scripts and importing modules observe it unchanged. Three cases keep the named read: non-strict compatibility mode, where a sloppy direct eval can shadow the name with a function-level var at run time; BigInt values, whose constant load rebuilds the value and costs more than the cached global read; and a coverage run, where a ternary or logical expression decided by the constant would otherwise be folded and lose its branch records.

What the compiler knows about a binding of an enclosing function, the constant value of a const or a trusted type, reaches a nested function only when no non-strict function lies between the read and the declaration. A sloppy direct eval in such a function can declare a var of the same name, which shadows the enclosing binding at run time. Strict code, the default, is unaffected.

Typed arithmetic opcodes (OP_ADD_FLOAT, OP_ADD_NUM_IMM and the rest) do not check their operands, so a binding's type hint has to hold at every read, including a read compiled before an assignment that reaches it through a loop's back edge or the join after a branch. A const takes the type of its initializer. A let without enforced type keeps a Number hint only when Goccia.Compiler.NumericBindings, which scans the whole function body before compiling it, proves that its initializer and every assignment to it produce a Number; an assignment never gives a binding a hint, and a var gets none because it can be read before its declaration runs.

When coverage is enabled, PreserveCoverageShape keeps constant branch structure in the emitted bytecode so coverage can report the non-hit branch instead of erasing it from the report. An if statement or a loop keeps its conditional jump, and so does a ternary or a &&, || or ?? expression that a constant decides: the compiler does not fold it, nor reduce flag && true or flag || false to flag under --strict-types. Its operands are still folded and propagated.

How Opcode Additions Work

New opcodes should be added only when an operation is both common enough and semantically stable enough to justify a dedicated instruction.

Prefer:

  • explicit Goccia opcodes for core language/runtime behaviour
  • compiler lowering to existing instructions for syntactic sugar
  • flags or operands when an operation is a mode of an existing instruction rather than a new concept

Object Integrity Levels

The VM has no property-flag primitives of its own; integrity levels belong to the shared object model. TGocciaObjectValue (Goccia.Values.ObjectValue.pas) declares virtual Freeze, Seal, PreventExtensions, TryPreventExtensions, TestIntegrityFrozen, and TestIntegritySealed:

  • Freeze and Seal follow ES2026 §7.3.15 SetIntegrityLevel: prevent extensions, then redefine each own key with [[Configurable]]: false (and [[Writable]]: false for data properties when freezing).
  • TestIntegrityFrozen / TestIntegritySealed follow §7.3.16 TestIntegrityLevel.
  • Objects with their own storage override some of these methods — TGocciaArrayValue, TGocciaModuleNamespaceObject, and the VM's TGocciaVMLiteralObjectValue among them — and the VM reaches them through the same virtual calls as the interpreter.

Spread Calling Consolidation

Spread-based calls use the flags byte on OP_CALL and OP_CALL_METHOD. Spread is treated as a mode of the call instruction rather than as a separate opcode family.

Rejected Findings

During code review, the following findings were investigated and determined to be non-issues:

  • SBIAS_24 (Goccia.Bytecode.pas) — The 24-bit signed bias constant 8388607 is correct. The 24-bit unsigned range 0..16777215 centered at 8388607 gives a signed range of −8388607..+8388608. This is standard Lua-style bias encoding.

Contributor Notes

  • Do not add new bytecode/runtime concepts under old generic naming.
  • Prefer TGoccia* bytecode and VM types in new code.
  • Keep interpreter and bytecode semantics aligned through shared runtime objects, not conversion layers.
  • Allocate an exception handler's register before compiling the region it protects. A throw skips the OP_CLOSE_UPVALUE instructions of the scopes it leaves, so when it reaches a handler the VM detaches the closure cell of every local slot above that handler's register.