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 —
TGocciaBytecodeExecutorimplementsTGocciaExecutorand drives only the compiler and VM; two residual couplings remain — directeval, and a module's top-level function declarations, which are created and run by the tree-walk evaluator - Goccia-owned VM — executes directly on
TGocciaValuewith taggedTGocciaRegistervalues; 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 —
.gbcfiles with little-endian encoding,GBC\0magic, 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
TGocciaVMviaTGocciaBytecodeExecutor
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
| Area | Units |
|---|---|
| Opcode definitions | Goccia.Bytecode.pas |
| Function templates / constants | Goccia.Bytecode.Chunk.pas |
| Module format | Goccia.Bytecode.Module.pas |
| Binary I/O | Goccia.Bytecode.Binary.pas |
| Debug metadata | Goccia.Bytecode.Debug.pas |
| VM execution | Goccia.VM.pas |
| Frames / closures / upvalues | Goccia.VM.CallFrame.pas, Goccia.VM.Closure.pas, Goccia.VM.Upvalue.pas |
| Bytecode executor | Goccia.Executor.Bytecode.pas (TGocciaBytecodeExecutor) |
| Numeric proof / lowering | Goccia.Compiler.NumericProof.pas, Goccia.Compiler.NumericBindings.pas, Goccia.Compiler.Expressions.pas |
| Opcode name lookup | Goccia.Bytecode.OpCodeNames.pas |
| Profiler | Goccia.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 rawnil. - 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 aRangeError: Maximum call stack size exceeded. Pass--max-stack=0to disable the limit. The memory ceiling then bounds the recursion: the VM's stacks and the call records it pushes are charged to--max-memoryand shrink back once idle, and a growth that does not fit is settled or refused with the sameRangeErrorat 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 sameRangeErroronce less thanNATIVE_STACK_RESERVEof 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'sexceptblock has ended, not inside it: on i386-win32 FPC runs anexceptblock 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 emitsOP_CHECK_TYPEwherever 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.ExecuteImportedModulegives its frame that frame's depth instead of one more, so--max-stackcounts 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 instructions128..166: non-core generic arithmetic/bitwise operations167..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;
99is unused - the non-core range holds the generic arithmetic operations
OP_ADD…OP_POW(129..134) and bitwise operationsOP_BAND…OP_USHR(136..141), plus the class/object helpersOP_DEFINE_PROP_DYNAMIC(128),OP_DEFINE_CLASS_METHOD_DYNAMIC(142),OP_SET_CLASS_SOURCE_CONST(143),OP_CLASS_ADD_METHOD_DYNAMIC(144), andOP_SET_PRIVATE_CLASS(145);135and146..166are 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 asOP_INC_NUMERIC(199),OP_GET_IMPORT_BINDING(213),OP_SUB_NUM_IMM(227),OP_CALL_SELF_NUM(229), andOP_JUMP_IF_NOT_LT(232) IsValidGocciaOpCoderejects the unused numbers99,135and146..166
The current encoding helpers are defined in Goccia.Bytecode.pas:
EncodeABCEncodeABxEncodeAsBxEncodeAxDecodeOpDecodeADecodeBDecodeCDecodeBxDecodesBxDecodeAx
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
argumentsobject creation andwithobject 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_YIELDdrives 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 throughOP_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 (
FArgumentStackwith 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 handsSetupNewFramea pointer to its argument registers, andSetupNewFramecopies 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
TGocciaExecutionContextunmanaged onPush/Pop: intern source paths as pointers instead of copying aUnicodeStringon 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,
BindToCurrentThreadcaches the thread'sTGocciaCallStack, a handle to its context stack and the address of itsGThreadPollsword (see Thread Variables on Hot Paths) on the VM; the dispatch loop testsFThreadPolls^.TimeoutArmedbefore it callsCheckExecutionTimeout, and reaches the call stack around a native call throughFCallStack; 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 clearsFNativeStackLimit, the native stack limit of the thread, which the first native entry nested more thanNATIVE_REENTRY_UNCHECKED_DEPTHdeep looks up and keeps. Nested entries reuse the binding, because they run inside the outermost one on the same thread -
keep
SetupNewFramefree 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 (InternExecutionSourcePathhandles a different path, out of line) -
decide whether a callee is a bytecode function with one exact class comparison in
OP_CALLandOP_CALL_METHOD:TGocciaBytecodeFunctionValueis sealed and a sibling of the native and bound function classes, so a bytecode callee skips theistests 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 - Int16asOP_SUB_NUM_IMMand conditionalNumber <= Int16asOP_JUMP_IF_NUM_NOT_LTE_IMMonly 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 < BasOP_JUMP_IF_NOT_LTso the compare andJUMP_IF_FALSEshare one dispatch; the opcode keeps generic<semantics (Number, BigInt, ToPrimitive/valueOf) and is not a Number-only shortcut -
fuse
local.identasOP_GET_LOCAL_PROP_CONST: one instruction that reads the local slot (including theOP_GET_LOCALTDZ hole check) and then the existingOP_GET_PROP_CONSTshape-lite IC; computed keys, optional chaining,withlookups, 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_IMPORTscales with declarations, while repeated identifier reads useOP_GET_IMPORT_BINDINGto 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_BINDINGfor a named import, aconstdefine 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_LOCALfirst. The copy does three things, and the compiler takes the direct read only where it can show that none is needed (TryResolveSettledLocalNameinGoccia.Compiler.Expressions.pas):- The TDZ check. A local is marked once the code that initializes it has been compiled: its declaration, a
for...ofor counted-loop binding, or the parameter preamble. The mark is forgotten at eachswitchclause, 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
constcannot be written. Aletbinding 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 (StatementCreatesNoClosureinGoccia.Compiler.OperandSafety.pasexamines 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 afinallyblock inlined at areturnorbreakis compiled inside a loop it does not belong to). Parameters of a function with anargumentsobject keep the copy, because a mappedargumentsobject writes parameter cells; so does a function from its first directevalonwards. A host that offers directevalat all (the Test262 host, and a ShadowRealm created from such a realm) marks its realm withHostsDirectEvalwhen it installs the function, and the executor passes that to the compiler asDirectEvalAvailable, which keeps the copy for everyletbinding and parameter: a closure created by directevalwrites registers by slot in whichever function calls it. - Taking the value before later operands run.
a + (a = 2)adds the old value, so aletbinding or a parameter is read directly only when no operand evaluated after it can write a local (ExpressionKeepsLocalRegisters): no assignment, increment, destructuring assignment, directeval,yieldorawait. A call is allowed, since the callee has its own registers and no closure over the binding exists.
Imports, global-backed bindings,
varbindings and destructured parameters keepOP_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 - The TDZ check. A local is marked once the code that initializes it has been compiled: its declaration, a
-
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 (
ParameterInitializerAvoidsOwnBindinginGoccia.Compiler.OperandSafety.pas): no identifier, assignment or destructuring target in it or in an earlier parameter names the parameter, none calls directeval, 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 andIsPrimitivesettle 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 sharedtrue/false), so a primitive held in an object register, a number object compared with itself and a hole still go toIsStrictEqual -
read standalone
thisproperties 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, becauseSetupNewFramestores 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) —TGocciaGlobalReadCacheEntryvalidates(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) —TGocciaPropertyReadCacheEntryvalidates 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) —TGocciaProtoReadCacheEntryproves 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 andEnsureShapewould have nothing to add to it. Only a level that fails that comparison callsEnsureShape, 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, sosetPrototypeOfis followed inherently; chain levels must be exactTGocciaObjectValue; 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) —TGocciaPropertyWriteCacheEntryis the write-side counterpart of the own-read cache: same(shape, entry index)validation, storing only own writable data properties.Writableand the descriptor kind are re-checked on every hit. Accessors, proxies, private fields, deletion, prototype mutation, non-writable descriptors, and non-ordinary receivers takeAssignProperty. 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.
TGocciaFunctionTemplatecarries a runtime-only table mapping a call/construct instruction's start PC to the callee as the author wrote it (aTGocciaCalleeDescriptorfromGoccia.Error.CallDiagnostics) plus the call expression's own line and column. The compiler fills it while emittingOP_CALL,OP_CALL_METHOD,OP_CONSTRUCT,OP_CONSTRUCT_SPREADand 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, soobj.missingMethod()reportsobj.missingMethod is not a functionrather thanundefined 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
ReferenceErrorreadsCannot access 'x' before initializationand a const assignment readsAssignment 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 atEndScope), 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 withOP_CHECK_BINDING_INITIALIZED, so a const still in its dead zone throws theReferenceErrorrather than theTypeError(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.gbcin the section the format already had; bytecode without them, such as a.gbcfrom an earlier build, falls back to the unnamedCannot 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:0and the runner with no line to render a code frame for.TGocciaCallStack.SetTopFrameLocationstamps 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 pernewreachingConstructValue— a native constructor such asnew Error(...)captures its trace during construction, before any throw, so its caller frame must be stamped up front (a small per-newcost: a binary search over the function's call-site table plus two string assignments).TGocciaVM.StampThrowLocationrecovers the current instruction for throw paths outside the dispatch loop through a pointer probe into the innermost loop'sTemplate/InstructionStartIPlocals, 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.ResolveFrameLocationssteps 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 itsFrame.IPprobe. The VM installs this resolver on the thread'sTGocciaCallStackfor 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.cwould be located at whatever operand the compiler emitted last.EmitLoadMemberPropertygives 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, throughBeginInstructionLocation/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'sstackstring is guest-writable and is never parsed to choose a file, a line, or an excerpt: a forgedthrow { 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'ssandbox.fs.pathgate cannot be bypassed through diagnostics. SeeGoccia.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--modulethe guest imported, or a heldErrorthe 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 (
RunModuleForSourceTypefor bytecode,Execute/ExecuteProgramfor the interpreter) — and around every cross-engine transition (ActivateRealmExecutionContextfor a ShadowRealmevaluate/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 toGoccia.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 afterEngine.Freeonly 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-memoryreservation, 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_SPREADsnapshots and restores the caller frame aroundConstructValue, 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 thenew). Native calls stamp the call site around the invoke, likewise restored, so a native callee's error carries a location rather than0:0. A throw skips those restores, soHandleExceptionUnwindclears 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=bytecoderuns 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 emitsOP_JUMP_IF_NOT_NULLISHin its nullish-match mode, soundefined,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_STRINGfor expression parts, then chainsOP_CONCATinstructions. - Object literals — Data properties compile to
OP_DEFINE_DATA_PROPso object initializers create or overwrite own enumerable data properties. Concise methods useOP_DEFINE_METHOD_PROPto attach[[HomeObject]]without changing plain data-property function or arrow values. Ordinary property assignment still uses theOP_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 useOP_INC_NUMERIC/OP_DEC_NUMERIC; postfix sites with distinct result and storage registers useOP_POST_INC_NUMERIC/OP_POST_DEC_NUMERICso 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++) => aorvar x = x++, produces it in a temporary and moves it into the local after the store. All variants preserve BigInt, convert other inputs throughToNumeric, 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 precedingOP_GET_LOCAL, so they perform its TDZ check themselves: a register still holding the hole throwsReferenceError. That check sits on the non-numeric path, so an update of a number costs nothing extra. An update of aconstreads and converts its operand in the same way, on a temporary register, and throws the assignmentTypeErrorwhere the store would be. - Traditional
forlexical bindings —let/constloop 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 usesOP_GET_LOCAL, and otherwise it stays a plainOP_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.
argumentsobject — With--compat-arguments-objectenabled, function templates snapshot the current call arguments in the frame.--compat-non-strict-modedoes not enable this helper by itself.OP_CREATE_ARGUMENTSmaterializes the object into the declared local slot before parameter defaults and body execution, so default initializers can observearguments.lengthand generators see the original call list after suspension/resume. OperandBselects mapped semantics for sloppy simple parameter lists and operandCcarries 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
varbindings — 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 oneOP_LOAD_UNDEFINEDper 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 afterthis, 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
thisbinding — Function templates serialize their strict-this mode. With--compat-non-strict-modeenabled for script source, ordinary function templates clear it so VM call paths coerce nullishthistoglobalThis; 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, andOP_SET_GLOBAL_LOOSEfor ordinary writes so failed[[Set]]results are ignored while null/undefined property access and throwing setters still raise errors. withstatement — With--compat-non-strict-modeenabled for script source, the compiler lowerswith (expr) bodytoOP_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 emitOP_HAS_WITH_BINDINGprobes 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 insidewith.- Non-strict
delete— With--compat-non-strict-modeenabled for script source, member deletes emitOP_DELETE_PROP_CONST_LOOSEorOP_DEL_INDEX_LOOSE, which preserve strict null/undefined errors but returnfalsefor non-configurable properties. Identifier deletes compile to local/upvalue false results,OP_DELETE_GLOBALfor global object property semantics, orwithbinding 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
.gbcformat - it does not track mutable bindings, imports, destructuring, or function/class declarations
- it only uses
--strict-typesfor 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:
FreezeandSealfollow ES2026 §7.3.15 SetIntegrityLevel: prevent extensions, then redefine each own key with[[Configurable]]: false(and[[Writable]]: falsefor data properties when freezing).TestIntegrityFrozen/TestIntegritySealedfollow §7.3.16 TestIntegrityLevel.- Objects with their own storage override some of these methods —
TGocciaArrayValue,TGocciaModuleNamespaceObject, and the VM'sTGocciaVMLiteralObjectValueamong 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.
Related documents
- Architecture — Shared source pipeline and both execution modes at a glance
- Interpreter — Tree-walk execution (
Goccia.Interpreter,Goccia.Evaluator.*) - Core patterns — Recurring Pascal conventions
- GocciaScript Context — Canonical project terminology
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_UPVALUEinstructions 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.