Files
2026-09-02 13:16:05 +00:00

503 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# quickjs-ng serialized bytecode — format specification
Produced by `qjsc -b`, and by `JS_WriteObject()` with `JS_WRITE_OBJ_BYTECODE`.
Applies to **quickjs-ng v0.16.1, `BC_VERSION 27`**. Hand-maintained against
`quickjs.c`; quickjs-ng raises `BC_VERSION` on any layout change, so re-check
after bumping `third_party/quickjs-ng`.
- **Part 1** — the wire format, as one declaration.
- **Part 2** — the algorithms it depends on.
- **Part 3** — the C structures behind it, and the enumerations.
- **Appendices** — differences from the original QuickJS; how to verify.
---
## Notation
The wire format is written as TypeScript type declarations. Two caveats,
since TypeScript is being used for its syntax rather than its semantics:
- **Field order is the wire order.** TypeScript object types are unordered;
here the declaration order is normative.
- **Widths come from the primitive aliases below**, not from `number`.
| Alias | Encoding |
|---|---|
| `u8` `u16` `u32` `u64` | Unsigned, fixed width, **native byte order** |
| `leb` | Unsigned LEB128, 7 bits per byte, at most 5 bytes |
| `sleb` | Signed LEB128, zigzag (§2.4) |
| `bit` | One bit inside a packed integer |
```ts
type u8 = number; type u16 = number; type u32 = number; type u64 = bigint;
type leb = number; type sleb = number; type bit = boolean;
```
An optional field written `x?: T` is present only under the condition stated
in its comment. A union of object types is a discriminated union: the shared
literal field is a real field on the wire, and its width is given in the
type's doc comment, since a TypeScript literal type carries no width.
Fixed-width integers are written with `memcpy`, so a file carries the
**producer's** byte order. Every mainstream target is little-endian; files are
not portable across endianness.
---
# Part 1 — Wire format
```ts
// ═══ container ═══════════════════════════════════════════════════════════
/** Repeats until end of file. The count is not stored. */
type File = SerializedObject[];
type SerializedObject = {
version: u8; // === 27, else reject
checksum: u32; // over bytes 5..end of object, §2.1
atom_count: leb; // limit 1_000_000
atoms: AtomEntry[]; // length = atom_count
root: Value; // MODULE, or FUNCTION for a classic script
};
// ═══ primitives ══════════════════════════════════════════════════════════
type JSString = {
header: leb; // (len << 1) | is_wide_char
data: u8[] | u16[]; // len Latin-1 bytes, or len UTF-16 units
};
/** `type` is a u8. */
type AtomEntry =
| { type: 0; raw: u32 } // a predefined atom number
| { type: 1 | 2 | 3; text: JSString }; // string | global_symbol | symbol
/**
* One leb. Decoding, where i = v >> 1:
* v & 1 -> tagged integer atom, value i
* i < first_atom -> predefined atom number i
* i >= first_atom -> atoms[i - first_atom]
*/
type AtomRef = leb;
// ═══ values ══════════════════════════════════════════════════════════════
/** `tag` is a u8. */
type Value =
| { tag: 1 } // NULL
| { tag: 2 } // UNDEFINED
| { tag: 3 } // BOOL_FALSE
| { tag: 4 } // BOOL_TRUE
| { tag: 5; value: sleb } // INT32
| { tag: 6; bits: u64 } // FLOAT64, IEEE-754
| { tag: 7; s: JSString } // STRING
| { tag: 8; count: leb; props: [AtomRef, Value][] } // OBJECT
| { tag: 9; count: leb; items: Value[] } // ARRAY
| { tag: 10; len: leb; mag: u8[] } // BIG_INT, two's complement LE
| { tag: 11; count: leb; cooked: Value[]; raw: Value } // TEMPLATE_OBJECT
| { tag: 12; fn: FunctionBytecode } // FUNCTION
| { tag: 13; module: Module } // MODULE
| { tag: 14; kind: u8; len: leb; offset: leb; buffer: Value } // TYPED_ARRAY
| { tag: 15; len: leb; max: leb; data: u8[] } // ARRAY_BUFFER
| { tag: 16; len: leb; max: leb; ptr: u64 } // SHARED_ARRAY_BUFFER, host pointer
| { tag: 17; pattern: JSString; lre_bytecode: JSString } // REGEXP
| { tag: 18; timestamp: Value } // DATE
| { tag: 19; inner: Value } // OBJECT_VALUE
| { tag: 20; index: leb } // OBJECT_REFERENCE
| { tag: 21; count: leb; entries: [Value, Value][] } // MAP
| { tag: 22; count: leb; items: Value[] } // SET
| { tag: 23; description: AtomRef }; // SYMBOL
// ═══ functions ═══════════════════════════════════════════════════════════
type FunctionBytecode = {
flags: FunctionFlags;
is_strict_mode: u8; // not part of flags
func_name: AtomRef;
arg_count: leb;
var_count: leb;
defined_arg_count: leb; // Function.prototype.length
stack_size: leb; // max operand stack depth
var_ref_count: leb;
closure_var_count: leb;
cpool_count: leb;
byte_code_len: leb; // in bytes
vardef_count: leb; // arg_count + var_count, or 0
vardefs: VarDef[]; // arguments first, then locals
closure_vars: ClosureVar[]; // length = closure_var_count
cpool: Value[]; // length = cpool_count; nested functions live
// here. NOTE: before the code
byte_code: Instruction[]; // byte_code_len bytes in total
debug?: DebugBlock; // present iff flags.has_debug
};
/** Packed into one u16, least significant bit first. */
type FunctionFlags = {
has_prototype: bit; // bit 0
has_simple_parameter_list: bit;
is_derived_class_constructor: bit;
need_home_object: bit;
func_kind: FuncKind; // bits 4..5
new_target_allowed: bit;
super_call_allowed: bit;
super_allowed: bit;
arguments_allowed: bit;
backtrace_barrier: bit;
has_debug: bit; // bit 11
};
type VarDef = {
name: AtomRef;
scope_level: leb; // 0 = not lexically scoped; §3.3
scope_next_plus_1: leb; // int32(v) - 1; -1 none, -2 arg-scope end
flags: VarDefFlags;
var_ref_idx?: leb; // present iff flags.is_captured
};
/** Packed into one u8, least significant bit first. */
type VarDefFlags = {
var_kind: VarKind; // bits 0..3
is_const: bit; // bit 4
is_lexical: bit; // bit 5
is_captured: bit; // bit 6
};
type ClosureVar = {
name: AtomRef;
var_idx: leb; // index in the PARENT frame
flags: ClosureVarFlags;
};
/** Packed into one leb, least significant bit first. */
type ClosureVarFlags = {
closure_type: ClosureType; // bits 0..2
is_const: bit; // bit 3
is_lexical: bit; // bit 4
var_kind: VarKind; // bits 5..8
};
type Instruction = {
opcode: u8; // §2.3 gives its size and operand format
operands: u8[]; // size - 1 bytes, fixed width per format
};
type DebugBlock = {
filename: AtomRef;
line_num: leb; // of the function's first character
col_num: leb;
pc2line_len: leb;
pc2line: u8[]; // §2.2
source_len: leb; // 0 when stripped
source: u8[]; // the function's own text
};
// ═══ modules ═════════════════════════════════════════════════════════════
type Module = {
module_name: AtomRef;
req_count: leb;
requests: AtomRef[]; // import specifiers
export_count: leb;
exports: ExportEntry[];
star_count: leb;
star_exports: leb[]; // indexes into requests
import_count: leb;
imports: ImportEntry[];
has_tla: u8; // top-level await
func_obj: Value; // the body; always a FUNCTION
};
/** `export_type` is a u8. */
type ExportEntry =
| { export_type: 0; var_idx: leb; export_name: AtomRef }
| { export_type: 1; req_module_idx: leb; local_name: AtomRef; export_name: AtomRef };
type ImportEntry = {
var_idx: leb;
import_name: AtomRef;
req_module_idx: leb;
};
// ═══ enumerations, §3.5 ══════════════════════════════════════════════════
const enum FuncKind { normal = 0, generator = 1, async = 2, async_generator = 3 }
const enum VarKind {
normal = 0, function_decl = 1, new_function_decl = 2, catch_ = 3,
function_name = 4, private_field = 5, private_method = 6,
private_getter = 7, private_setter = 8, private_getter_setter = 9,
using = 10, using_method = 11,
}
const enum ClosureType {
local = 0, arg = 1, ref = 2, global_ref = 3,
global_decl = 4, global = 5, module_decl = 6, module_import = 7,
}
```
## Notes on the above
Things the types cannot state.
1. **`first_atom` is not in the file.** It is `JS_ATOM_END` (242) when the file
contains bytecode and 1 otherwise. The engine takes it from the caller's
`JS_READ_OBJ_BYTECODE` flag, so a reader must be told which applies.
2. **Atom operands inside `byte_code` are bare `u32` with no shift.** They use
the same index space as `AtomRef`, but `JS_WriteFunctionBytecode()` writes
them with `bc_atom_to_idx()` rather than `bc_put_atom()`, so the `v >> 1`
step does not apply there.
3. **Functions nest through `cpool`, and only through `cpool`.** A
`FunctionBytecode` appears in exactly two places: `Module.func_obj`, the
module body, and `FunctionBytecode.cpool[]`, a nested function. There is no
list of functions anywhere. Since `cpool` also precedes `byte_code` — so a
reader can resolve a `push_const` operand the moment it decodes it — a
nested function's entire encoding sits inside its parent, ahead of the
parent's first instruction.
4. **`VarDef` is variable-width** because of the optional `var_ref_idx`.
Reading or skipping it unconditionally desynchronises the rest of the table.
5. **Jump operands are signed offsets from the operand field's own position**:
target = `operand_offset + value`. For the `atom_label_*` formats the base
is the label field, 4 bytes into the operands.
6. **`0xffffffff` in `checksum` disables the check.**
7. **Tags 14–16, 18, 21 and 22 never come from compiled JavaScript**; they
arise from serializing live objects. A regexp *literal* compiles to two
`STRING` constants plus a `regexp` opcode, not to tag 17.
8. **`source` is required by `Function.prototype.toString()`**, not merely
diagnostic. `qjsc -s` empties it; a second `-s` clears `has_debug` and
removes `DebugBlock` entirely.
---
# Part 2 — Algorithms
## 2.1 Checksum
Over the object's bytes from offset 5 to its end, compared with the `u32` at
offset 1.
```c
uint32_t bc_csum(const uint8_t *p, size_t n) {
uint32_t a, b, c, h = 0;
size_t i;
for (i = 0; i + 4 < n; i += 4) { /* note: <, not <= */
h += get_u32(p + i);
h *= 0x9e370001;
}
a = b = c = 0;
switch (n - i) { /* note: no case 4 */
case 3: c = p[i+2]; /* fallthrough */
case 2: b = p[i+1]; /* fallthrough */
case 1: a = p[i]; /* fallthrough */
case 0: break;
}
h += a | b << 8 | c << 16;
return h * 0x9e370001;
}
```
The loop stops while up to four bytes remain and the tail `switch` has no
`case 4`, so a trailing group of exactly four bytes contributes nothing.
Reproduce it exactly or valid files fail verification.
## 2.2 pc2line decoding
Produces checkpoints mapping a bytecode offset to a source position. State
starts at `pc = 0`, with `line` and `col` from `DebugBlock`. Constants:
`PC2LINE_BASE = -1`, `PC2LINE_RANGE = 5`, `PC2LINE_OP_FIRST = 1`.
```
while bytes remain:
op = read u8
if op == 0: // escape for large deltas
pc += read leb
line += read sleb
else:
op -= PC2LINE_OP_FIRST
pc += op / PC2LINE_RANGE
line += op % PC2LINE_RANGE + PC2LINE_BASE
col += read sleb // in both branches
emit checkpoint (pc, line, col)
```
One byte therefore encodes a small `pc` advance together with a line delta in
the range −1..+3.
## 2.3 Opcode resolution
Short opcodes fold an operand into the opcode byte, and their numbers overlap
the compiler's temporary opcodes, which never appear in a file. Resolve as the
engine does:
```c
#define short_opcode_info(op) \
opcode_info[(op) >= OP_TEMP_START ? (op) + (OP_TEMP_END - OP_TEMP_START) : (op)]
```
For v0.16.1: `OP_TEMP_START` = 185, temporary block = 19, real opcodes = 252.
Omitting the shift yields both the wrong instruction and the wrong width.
Every opcode, its size and its operand format are in
`tests/report/OPCODES.md`.
## 2.4 Signed LEB128
Zigzag over unsigned LEB128.
```
encode: leb( (2 * v) ^ -(v >> 31) )
decode: (u >> 1) ^ -(u & 1)
```
---
# Part 3 — Why the format looks like this
Facts about the engine that explain choices in Part 1. The C structures
themselves are in `quickjs.c`; only what bears on the wire is here.
## 3.1 Atoms exist only at run time
A `JSAtom` is a `uint32_t` index into `JSRuntime.atom_array`, shared by
everything in the process and dependent on what was interned and when. It is
meaningless in another process.
`AtomEntry[]` therefore corresponds to no structure. `JS_WriteObjectAtoms()`
creates it: it walks the object, collects every atom referenced, writes them
as a file-local table, and rewrites each atom field as an index into it.
Indexes below `first_atom` are engine built-ins needing no entry, which is why
only atoms the engine did not already know are written out.
## 3.2 What is not serialized
`JSFunctionBytecode` holds a GC header, a `JSContext *realm`, and a pointer to
each of its arrays. None of that is written: pointers become inline data, and
the header and realm are rebuilt on load. `JSModuleDef` is mostly link and
evaluation state — `module_ns`, `status`, `dfs_index`, `cycle_root`,
`promise`, `async_parent_modules` — and `Module` stores only the static shape
the compiler determined.
Two fields are wire-only, with no structure behind them:
- **`has_debug`** records the writer's `allow_debug` setting, which is how
`qjsc -s -s` produces a function with no `DebugBlock`.
- **`vardef_count`** is redundant with `arg_count + var_count`; the writer
emits it anyway, and 0 when `vardefs` is absent.
One field is structure-only: `JSVarDef.is_static_private` is used while
parsing private class fields and never written, which is why `VarDefFlags`
carries seven meaningful bits and not eight.
## 3.3 The scope chain
`scope_level` and `scope_next` are a fragment of the compiler's scope tree,
serialized for one consumer: direct `eval`.
`scope_level` indexes `fd->scopes[]`, an array that is **not** serialized, so
the number carries no meaning of its own — only equality does. Two variables
with the same level were declared in the same block.
Level 0 is the exception and is universal: it is the function scope, so
`scope_level == 0` means the variable is not lexically scoped — a `var`, a
hoisted function declaration, or a pseudo-variable such as `this`. Level 1 is
`ARG_SCOPE_INDEX`, the argument scope, which is also the body scope unless the
function has parameter expressions. Only then does the body get a scope of its
own, at level 2, and only then can an argument appear twice: once in the
argument half of `vardefs` and once as a lexical copy at level 1.
`scope_next` is a singly linked list, `-1` at the end. It indexes the
**locals**, that is `vardefs[arg_count + scope_next]`, not `vardefs` directly.
`js_create_function()` rebuilds the chain immediately before the function is
written: the variables of one block link to each other, and the last of them
links to the block outside it. Following `scope_next` from any variable
therefore walks its own block and then the blocks around it, outward.
```js
function h() {
var v = 1; // level 0 not in the chain
let w = 2; // level 1 next -1 end
{ let u = 3; } // level 2 next 1 -> w
}
```
`OP_eval` and `OP_apply_eval` hold the entry point: a `u16` operand carrying a
local index biased by +1. At run time `add_closure_variables()` starts there,
follows `scope_next`, and makes a closure variable out of every variable it
reaches, which is how evaluated code sees the caller's locals. Nothing else in
the engine reads either field. A function that contains no direct `eval` still
carries the chain.
Two cases do not follow the rules above:
| Where | Value | Meaning |
|---|---|---|
| `scope_next` | `-2`, stored as `0xffffffff` | `ARG_SCOPE_END`: stop here, and do not add the arguments. Written only when the function has parameter expressions — a default value or a destructuring pattern. |
| any entry with `scope_level` 0 | `scope_next` arbitrary | not maintained. The whole argument half of `vardefs` is level 0, and so is every `var`. `JS_DUMP_BYTECODE_FINAL` prints the pair only when `scope_level != 0`, for this reason. |
Because of `ARG_SCOPE_END` the field must be decoded as a **signed** 32-bit
value before the −1, matching `bc_get_leb128_int()`. Read as unsigned, −2
becomes 4294967294.
## 3.4 The capture pairing
`VarDef` and `ClosureVar` are the two halves of one capture. A parent marks a
local `is_captured` and assigns it a `var_ref_idx`; the child lists the same
variable with `closure_type = local` and a `var_idx` pointing into the
parent's frame. Either side read alone is incomplete.
`ClosureType.local` and `.arg` index the parent's variables and arguments
respectively; the remaining six index the parent's own closure variables, or
describe a global or module binding.
## 3.5 Enumeration names
| TypeScript | C |
|---|---|
| `FuncKind` | `JSFunctionKindEnum` — one bit for generator, one for async, so the four values compose |
| `VarKind` | `JSVarKindEnum` — `catch_` is `JS_VAR_CATCH`, renamed only to avoid the reserved word |
| `ClosureType` | `JSClosureTypeEnum` |
| `ExportEntry.export_type` | `JSExportTypeEnum` |
`VarKind.using_method` is a hidden local holding the cached dispose method for
the preceding `using` variable, always allocated immediately after it.
## Appendix A — Differences from the original QuickJS
Read from Bellard's `quickjs-2026-06-04`, the release current when this was
written. Both projects are active, so date the comparison before trusting it.
| Aspect | Bellard 2026-06-04 | quickjs-ng v0.16.1 |
|---|---|---|
| `BC_VERSION` | 5 | 27 |
| Header | Version byte, then the atom count | Version byte, then a `u32` checksum |
| Atom entries | String only | Type byte, then a string or a raw number |
| Function body order | Code, debug, then constant pool | Constant pool, then code, then debug |
| Byte after flags | `js_mode` | `is_strict_mode` |
| Tags | 19; no `MAP`, `SET`, `SYMBOL`, `REGEXP` | 23; later tag numbers shifted |
| Debug block | Filename and `pc2line` only | `line_num` and `col_num` as well |
| `VarDef` | No `scope_level`. `var_ref_idx` always written. Extra `has_scope` flag bit | `scope_level` present. `var_ref_idx` only when captured |
| `ClosureVar` flags | `u16` | leb128 |
| Opcodes | 244. `get_var` and its three siblings take a 3-byte `var_ref` index | 252. The same four take a 5-byte atom |
The pool order is the one that matters to a reader. ng moved it ahead of the
code on purpose — its own comment says *"write constant pool before code so
code can be disassembled on the fly at read time"* — so a `push_const` operand
resolves as soon as it is decoded.
## Appendix B — Verifying against a file
The listing tool prints one row per field, in the order given above:
```sh
cargo run -p qjsdump -- tests/bin/basic.bin
```
The parser is written in nom, one function per type in Part 1, so
`crates/qjs-bc/src/{parse,atoms,value,function,module}.rs` reads as an
executable form of this document. Indentation in the listing is depth in
`cpool`: a nested function and its sections are indented one level under the
parent whose constant pool holds them, which is also why a parent's `.code`
appears after, but less indented than, the whole of a child.
`tests/report/` holds a listing per fixture and `OPCODES.md`; `make`
regenerates both from `tests/src/`.