Every function, table, memory and global a WebAssembly module can refer to has a number, and those numbers are the format's only name. What decides a thing's number is not where it is declared but whether it is imported, and that one rule explains more about the format than anything else in this module.
Imports come before definitions in the section order, and they come before definitions in the index space too. A module that imports one function and defines two has three functions, numbered 0, 1 and 2, and the two it defined are 1 and 2 — not 0 and 1. The same rule holds independently for tables, memories and globals, each with its own numbering.
It is worth understanding why a format would do this rather than number definitions first. An import is a claim about something outside the module, and a definition is a claim inside it. Putting the outside claims first means every index below the import count is guaranteed to exist, and everything above it is something the module can prove. A validator can check the boundary once and know the provenance of every subsequent number. It is the same reasoning as a linker's symbol resolution order, and it is why the rule is not negotiable from a reader's point of view even though a producer could have chosen otherwise.
The section's shape, and the part that trips people:
The last line is the whole difficulty. What follows the kind byte is a different structure for each kind, and a reader that treats it uniformly will read a limits flag as a type index and produce a plausible nonsense:
| Kind | Body | Shape |
|---|---|---|
| 0 func | type index | one LEB128 — the smallest body in the format |
| 1 table | element type + limits | a valtype byte, then a limits record |
| 2 memory | limits | just a limits record |
| 3 global | value type + mutability | two bytes — note: no initialiser, because the host supplies the value |
That third row deserves a sentence of its own. An imported global has no initial value in the file, and a global.get of it before the host sets it is a trap the validator cannot catch. The host owns the initial contents of everything it imports, which is the entire point of importing it.
Three imports of three different kinds in a thirty-five byte section. Here are all of them:
001c: 03 03 65 6e 76 03 6c 6f 67 00 02 03 65 6e 76 05 002c: 74 61 62 6c 65 01 70 00 01 03 65 6e 76 02 67 76 003c: 03 7f 00
Walk all three, because the point of the exercise is that they are three different structures:
03 THREE imports import 0 -- a function 03 65 6e 76 module "env" 03 6c 6f 67 field "log" 00 kind 0: FUNC 02 type index 2 import 1 -- a table 03 65 6e 76 module "env" 05 74 61 62 6c 65 field "table" 01 kind 1: TABLE 70 element type: funcref 00 limits flags: minimum only 01 minimum = 1 import 2 -- a global 03 65 6e 76 module "env" 02 67 76 field "gv" 03 kind 3: GLOBAL 7f value type: i32 00 mutability: const
Three bodies, three shapes, and the sizes alone would let you tell them apart: 10 bytes, 14 bytes, and 10 bytes. Now the consequence that matters more than any of the bytes, which is what this does to the numbering.
The same module also declares two functions, a table, a memory and two globals. wabt's own reading of the whole module, which numbers everything in the global index space:
Import[3]:
- func[0] sig=2 <env.log>
- table[0] type=funcref initial=1 <env.table>
- global[0] i32 mutable=0 <env.gv>
Function[2]:
- func[1] sig=0 <add>
- func[2] sig=1
Table[1]:
- table[1] type=funcref initial=4
Memory[1]:
- memory[0] pages: initial=2 max=8
Global[2]:
- global[1] i32 mutable=0 - init i32=42
- global[2] i64 mutable=1 - init i64=-1
Export[3]:
- func[1] <add> -> "add"
- memory[0] -> "mem"
- global[0] -> "gv"Read the numbers and the rule is visible without being told:
func[0], so the two defined functions are func[1] and func[2]. The export "add" points at func[1] — index 1, not 0.table[0], so the defined table is table[1].global[0], so the two defined globals are global[1] and global[2]. And the export "gv" exports global[0], which is the imported one — the module is re-exporting something the host gave it, which is legal and is how a wrapper library forwards a capability.memory[0]. The numbering is unaffected, because there was nothing to offset it.func[0] be an import while table[0] is a definition, and there is no relationship between the two counters. A reader that keeps "the next number" in a single variable will be correct on the first kind it meets and wrong on the second, which is the most annoying possible failure: it works on simple modules and breaks on the first real one.Because imports are counted first and definitions second, the import section's count is a boundary in every index space, and the boundary for each space is the number of imports of that kind. In this module:
functions 1 imported -> func[0] is imported tables 1 imported -> table[0] is imported memories 0 imported -> memory[0] is a definition globals 1 imported -> global[0] is imported
That is the property a validator exploits. It can read the import section, note that func[0] must be supplied by the host, and then treat every index it meets ≥ the count as something the module itself has to define. A number below the boundary is someone else's problem; a number above it is this module's. And the count also gives a free existence check: an export or a call naming an index below the boundary is referring to an import, which is legal, while one naming an index at or above the boundary for something that was never defined is a validation error.
That is a real structural property, not a convenience. It means a validator can answer "is this index valid" without having read the whole module, for the lower half of every space, and it is the same kind of separation that lets a module be linked in stages: resolve the imports, and the definitions become a self-contained unit with a known base.
Resolving a call, which is the operation that makes the whole rule matter. A call instruction carries a function index, and the job is to turn that number into something executable:
Two things in that are easy to get wrong, and both are silent. The subtraction is not optional — a reader that forgets it will resolve a call to the wrong function, and since the body count usually exceeds the import count the wrong function is usually a real one, so nothing complains. And the per-kind count matters: a module that imports three functions and one table has an imported function count of 3 and an imported table count of 1, and a reader that used one number for both would be off by two on every table reference after the first.
That failure is worth spelling out because it is invisible in the common case. Write a module with no imports at all: the counts are all zero, the subtraction is a no-op, and a reader with no offset logic passes every test you can write. Add one import and every call in the module starts pointing one function too low — at a valid function, of a valid signature, which is why the module keeps working until something happens to make the wrong answer visible. A bug that only appears when you add data is a bug your test suite was not built to find, and the way to build for it is to test the smallest case and the boundary case, not the middle.
The same structure, with the kind byte selecting which counter to use, is what an export needs — and it is why the export section's kind byte is the mirror of the import section's. An export says "this name refers to that kind, at that number", and the two fields together pick the index space. The export of "gv" as global[0] in this module is a case where the number is below the boundary, so it names an import; exporting a definition would have said global[1].
$ wasm-objdump -x decls.wasm | sed -n '/^Import/,/^Function/p'
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex decls.wasmcall instruction each time. The opcode is identical and the index is identical; what changes is which function it names. That is the entire lesson, and three compiles is all it costs.decls.wasm. It will be correct for the first kind it encounters and wrong for the second, which is exactly the failure signature that makes this bug so hard to find. Then fix it with five separate counters and confirm.table instruction naming table[0] refers to the import while a func instruction naming func[0] refers to a different one. Two index spaces, two different meanings for the same number, one module."gv" export, which forwards the host's global, and rename the local one too. Confirm both appear and that their indices are global[0] and global[1]. Forwarding a capability by re-exporting it is a common and useful pattern, and it is only possible because the index spaces are explicit.wasm-objdump -x. It is a tiny program and it is the exact thing a validator does first, so building it teaches the order the format expects a consumer to work in.call in the code section names an index below the import count — it should, if the module calls an imported function, and that is the ordinary case rather than an oddity.Without looking back: a module imports two functions, one table, zero memories and one global. It then defines three functions, one table, one memory and two globals. What are the indices of the defined memory and of the third defined function, and what does the exported index 0 in the global space refer to?
Your tool links a module by replacing calls to imported functions with the module's own. It works on small modules. On a large one, several functions return values that belong to entirely different functions — plausible values, right types, wrong answers — and only for modules that import anything. Modules with no imports are fine, which is the clue. Nothing errors and the module is valid. What is the defect, and what is the shortest test that would have caught it before a user did?
The general habit: when a bug only appears when an optional structure is present, find the branch that handles that structure. And prefer a differential test to a unit test — comparing two runs that should agree catches what comparing a function to itself cannot.
The index-space rule is the WebAssembly member of a family this collection has met twice. In the PE import directory and in ELF's dynamic symbol table, the same problem appears in a different shape: a module refers to an external symbol by name at link time and by index at load time, and the loader has to build the mapping. WebAssembly inverts it — the index is the only reference that survives, and the name is resolved before the file is ever used.
That inversion is what makes a WebAssembly module self-contained in a way an ELF object is not. An object file is full of undefined symbols and meaningless without a link step; a WebAssembly module is fully resolved except for its declared imports, and those are named explicitly with a module and field. The format chose to make the unresolved set explicit and small, rather than implicit and large, which is the same trade a .lib archive makes by carrying a symbol index so a linker can find a member without opening every member.
The per-kind bodies connect to the type section and the COFF section characteristics. In both cases a single-byte tag selects among several record shapes, and in both cases a reader that ignores the tag will not fail loudly — it will read a byte from one structure as if it belonged to another. The reason a tag exists at all is that the format cannot be extended by adding a new structure type without one, and that is a lesson worth carrying to any format you meet next.
One more thing the import section sets up, which the last module of this course returns to: an imported global has no initial value in the file. The host owns it. That is a small fact and it is the cleanest example in the format of the difference between what a file can express and what it deliberately leaves to somebody else — the same division the object format draws when it puts a linking section in a custom section the core specification knows nothing about.
Next: Tables, Memories and Limits.