Custom Sections and Debugging
A module can carry sections that engines ignore: custom sections, with id 0. They hold anything a toolchain wants to attach to a module, and in practice that means names for functions and locals, information about the tools that produced the module, and debugging information. This chapter covers the format, the name section that every compiler should emit, and how debugging works in a browser.
#The format14.1
A custom section is a section with id 0 whose contents start with a name, the section's own name, followed by whatever bytes its producer likes:
00 section id: custom
size u32
name a name: the custom section's name
payload the rest of the section
Custom sections can appear anywhere, any number of times, even between known sections. An engine must accept a module whose custom sections it does not understand, so they never affect whether a module validates or how it runs, with one narrow exception: an engine may report a malformed name section as a warning.
Here is a module with a custom section added by hand, using the encoder helpers from the last chapter:
def uleb(n):
out = bytearray()
while True:
byte, n = n & 0x7f, n >> 7
out.append(byte | (0x80 if n else 0))
if not n:
return bytes(out)
def name(s):
b = s.encode("utf-8")
return uleb(len(b)) + b
def section(id, contents):
return bytes([id]) + uleb(len(contents)) + contents
import subprocess
wasm = b"\x00asm\x01\x00\x00\x00" + section(0, name("build-info") + b"engine-v3 2026-11-01")
open("custom.wasm", "wb").write(wasm)
print(subprocess.run(["wasm-objdump", "-x", "custom.wasm"], capture_output=True, text=True).stdout)
custom.wasm: file format wasm 0x1 Section Details: Custom: - name: "build-info"
JavaScript can read custom sections from a compiled module, which makes them a convenient place to put metadata a game engine wants to read before instantiating a module, such as a version, or a list of assets a script needs:
wasm = b"\x00asm\x01\x00\x00\x00" + section(0, name("build-info") + b"engine-v3 2026-11-01")
open("custom.wasm", "wb").write(wasm)
import fs from 'node:fs';
const module = new WebAssembly.Module(fs.readFileSync('custom.wasm'));
for (const buf of WebAssembly.Module.customSections(module, 'build-info'))
console.log(new TextDecoder().decode(buf));
$PYTHON make.py
node read.mjs
engine-v3 2026-11-01
#The name section14.2
The custom section named name maps indices to names, for tools to show.
Without it, a stack trace or a profile shows functions as numbers; with
it, they have the names your compiler chose. It costs nothing at runtime,
and it is by far the most useful custom section, so a compiler should emit
it at least in development builds.
The payload is a sequence of subsections, each a one-byte id, a size, and contents. The standard ones are:
| Id | Subsection | Contents |
|---|---|---|
| 0 | module | the module's name |
| 1 | function | a name map: a vector of (function index, name) pairs |
| 2 | local | for each function, a name map of its locals |
Later additions (from the extended name section proposal) give names to labels (3), types (4), tables (5), memories (6), globals (7), element segments (8), data segments (9), struct fields (10) and tags (11). Tools support these unevenly, but they cost nothing to emit.
A name map must be sorted by index, and each subsection may appear at most once, in order of id.
The wat crate's assembler writes a name section with every $name in the
text, which is why wasm-objdump has been able to show names throughout
this book:
(module $physics
(func $integrate (param $pos f32) (param $vel f32) (param $dt f32) (result f32)
(f32.add (local.get $pos) (f32.mul (local.get $vel) (local.get $dt))))
(func $step (export "step") (result f32)
(call $integrate (f32.const 0) (f32.const 2) (f32.const 0.5))))
wasm-objdump -x -j name named.wasm | tail -n +5
Section Details: Custom: - name: "name" - module <physics> - func[0] <integrate> - func[1] <step> - func[0] local[0] <pos> - func[0] local[1] <vel> - func[0] local[2] <dt>
#Names in stack traces14.3
When a trap or an exception passes through WebAssembly code, the
JavaScript Error it produces has a stack trace with a line for each
WebAssembly frame. Here is a trap three calls deep, in a module with
names:
(module
(func $inner (unreachable))
(func $middle (call $inner))
(func $outer (export "main") (call $middle)))
import fs from 'node:fs';
const file = process.argv[2];
const { instance } = await WebAssembly.instantiate(fs.readFileSync(file));
try {
instance.exports.main();
} catch (e) {
const frames = e.stack.split('\n').filter((l) => l.includes('wasm://'));
// the URL's hash depends on the module's bytes, so it is left out here
for (const f of frames) console.log(f.trim().replace(/wasm:\/\/wasm\/[0-9a-f]+/, 'wasm://wasm/…'));
}
node trace.mjs deep.wasm
at inner (wasm://wasm/…:wasm-function[0]:0x23) at middle (wasm://wasm/…:wasm-function[1]:0x27) at outer (wasm://wasm/…:wasm-function[2]:0x2c)
Each frame shows the function's name, its index, and the byte offset of
the instruction that was executing, from the start of the module. And
here is the same module without its name section, removed by
wasm-strip, which strips every custom section:
wasm-strip deep.wasm
node trace.mjs deep.wasm
at wasm://wasm/…:wasm-function[0]:0x23 at wasm://wasm/…:wasm-function[1]:0x27 at wasm://wasm/…:wasm-function[2]:0x2c
Without names, each frame says only which function index it was in. You can look those up yourself in a build that kept the names, provided the function indices are the same in both, which they are if stripping is the only difference. That is the usual approach for release builds: ship a stripped module, keep the unstripped one, and translate stack traces from the field after the fact.
The offsets are the same in both, because an offset counts from the start of the module, not the function, and the name section comes after the code. Toolchains put it last for exactly this reason: adding or removing names does not move any code.
#Producers and features14.4
Two other custom sections are common in the wild:
producersrecords the languages, tools and SDKs that produced the module, as name and version pairs. Browsers use it for telemetry about which toolchains are in use; it has no effect on execution.target_featuresis written by LLVM into object files, to record which features (SIMD, bulk memory, and so on) the code uses, so the linker can check that everything linked together agrees.
#Source maps and DWARF14.5
A name section tells a debugger which function it is in. To show the source line, it needs a mapping from byte offsets in the code to positions in the source. There are two standard ways to provide one.
Source maps are the format JavaScript minifiers use: a JSON file
mapping generated positions to source positions. For WebAssembly, the
"generated position" is a byte offset in the code section. A module
points at its map with a custom section named sourceMappingURL, whose
payload is the map's URL as a name. Chrome, Firefox and Safari all
support this, and it is the simplest option for a new compiler: you emit
one JSON file alongside the module, and every browser's debugger can then
step through your source, line by line. Source maps cannot describe
variables, types or scopes, only positions.
DWARF is the debugging format native compilers use, and LLVM writes
it into WebAssembly as custom sections named .debug_info, .debug_line
and so on, exactly as it would in an ELF file, with code addresses
replaced by code-section offsets. It describes everything: lines,
variables, types, scopes and inlining. Chrome supports it through a
DevTools extension for C and C++, and can show local variables and
evaluate expressions in source terms. DWARF is far more work to produce
than a source map, and makes modules much bigger, so it usually lives in a
separate file named by a custom section called external_debug_info.
#Debugging in the browser14.6
In Chrome or Firefox, a module appears in the debugger's source list
under wasm://. Without a source map, the debugger shows the
disassembled text format, and lets you set breakpoints on instructions,
step, and inspect locals, globals, the operand stack and linear memory.
With names, everything has the names your compiler gave it. That alone is
enough to debug a compiler's output: put a breakpoint where something goes
wrong, and compare what the machine is doing with what your compiler meant
it to do.
Two more facilities are worth knowing about:
- Memory inspection. Chrome's Memory Inspector panel shows a linear memory as bytes and lets you jump to an address, which is invaluable for checking struct layouts and the shadow stack.
- Logging imports. The cheapest debugging tool remains an imported
printfunction. A compiler flag that inserts calls to a trace import at each function entry, with the function index as an argument, gives you a call log for free.
#What you have seen14.7
Custom sections, id 0, carry a name and arbitrary bytes, and engines
ignore them. The name section maps function, local and other indices to
names, and makes stack traces, profiles and the debugger readable. Source
maps, pointed to by a sourceMappingURL section, give browsers line-level
debugging cheaply; DWARF gives full source-level debugging at greater
cost. Stripping custom sections for release, and keeping an unstripped
copy, is the usual practice.
That ends Part IV. Part V turns to the JavaScript side of the boundary.