Odin Speaks Odin ยท Appendix D

Appendix D: Troubleshooting

Every trap collected here is a real one, hit while writing this book's own reference code, each already flagged as a Gotcha callout in its own chapter. This appendix gathers them in one place for a reader debugging their own implementation who doesn't remember which chapter the answer was in.

Lexing and parsing

A syntax error points at the wrong line, or spans seem to drift. Check that every token carries its own line/column from the moment it's created (lexer.odin), not derived later from some other state. Retrofitting spans after the fact is painful precisely because every later pass, the parser, the resolver, the compiler's own line table, assumes they're already correct; build them in from the very first token (Chapter 2's own Gotcha).

A statement that should end at a newline keeps consuming the next line too, or vice versa. Automatic semicolon insertion (lexer.odin's ends_statement) only inserts a .Semicolon after a fixed set of token kinds. Two shapes trip this up specifically: } else { must keep the brace and else on the same line, or ASI inserts a semicolon right after } and the else becomes a syntax error (Chapter 3's own Gotcha); and a return/yield value must start on the same line as the keyword, or ASI inserts a semicolon immediately after return, silently turning return\n x into a bare return followed by a dangling expression statement (Chapter 4's own Gotcha).

Fixed-width printed output (offsets, line numbers) shows leading zeros where spaces were wanted. fmt's %4d-style width specifiers pad with '0', not a space, even when the value is left-aligned; if a column of numbers needs to look like 5 rather than 0005, that padding character has to be handled explicitly (Chapter 2's own Gotcha).

A malformed token at the very end of a file causes the parser to hang or double-report. parse_primary's own error-recovery fallback advances past one token before returning an error node; check what happens when that token was already the last one before EOF, since advancing past EOF itself is exactly the kind of edge a fallback path can miss (Chapter 4's own Gotcha).

Bytecode compilation

A forward reference to a function declared later in the same file fails to resolve, or resolves to the wrong thing. Top-level functions must be hoisted, declared and bound in one pass over the whole file, before any of the file's own top-level statements run, in both the resolver (resolve_program) and the compiler (compile_program) and the tree-walker (exec_program). Compiling in strict file order instead turns "a() calls b(), b declared later" into a real, order-dependent bug the moment a() happens to run before b's own Define_Global has executed (Chapter 12's own Gotcha).

A jump lands one byte early or late, or a loop runs one too many/few times. Every jump's offset is measured from the byte immediately after the jump instruction's own two operand bytes, never from the jump opcode's own position. patch_jump/emit_loop (compiler.odin) both get this right; if a hand-written jump doesn't match, recheck which offset the arithmetic is actually measured from. musc compile's own resolved-target column (0009 JUMP_IF_FALSE 000011 -> 0023, Appendix B) does this arithmetic for you and is the fastest way to confirm a jump lands where it should.

The runtime VM

An arithmetic or comparison opcode panics instead of failing gracefully. Every one of ADD through GREATER_EQUAL (Appendix B) must reach for as_number/as_number_pair (muspel/vm.odin, section 18.6), never an unchecked .(f64) type assertion. A raw value.(f64) on a non-number Value is an Odin panic, not a runtime_error; this was a real, deliberately-deferred gap through Chapter 14 and closed in Chapter 18, not a hypothetical.

A runtime error message is correct, but the trace it prints points at the wrong source line. runtime_error reads the failing instruction's own line as chunk_line_at(f.chunk, f.ip - 1), never f.ip itself. By the time any opcode case calls runtime_error, read_u8/read_i16 have already advanced ip past every byte of the instruction that's actually failing; ip - 1 is what lands back inside that instruction's own byte range regardless of how many operand bytes it had (section 14.3).

Firing a stored callback (an event handler, an on_death handler) corrupts a value that was sitting nearby on the stack, especially once two are fired without draining the first. Any code that builds a call frame by hand, rather than through the compiler's own Call opcode, must push the callee itself before its arguments, exactly the shape ordinary bytecode already leaves on the stack before Call runs. Return always resizes the stack back to frame.base - 1 and writes its result there; skip pushing that slot and the resize eats one slot too many, silently overwriting whatever Value was already sitting just below where the call's own arguments started. This surfaced as a real bug in vm_fire_event's own first draft, invisible with a single in-flight callback and only exposed once two were suspended at once (Chapter 17's own Gotcha); vm_call_value (muspel/vm.odin) is the fixed, canonical version.

A fired callback's own ticket (Callback_Call) resumes the wrong call. Tickets must be drained last-fired-first, the same discipline ordinary nested function calls already require. vm_resume_call doesn't check which call is actually on top of vm.frames, only where the frame stack needs to unwind back down to; calling it with an earlier ticket while a later call is still suspended above it doesn't corrupt anything, but it silently drives the wrong call instead (section 17.1's own Gotcha).

An instruction budget trips on a workload that should be completely legitimate. A hard instruction cap (Instruction_Hard_Cap, muspel/vm.odin) chosen without measuring an actual heavy, real workload against it is a guess; the value the reference code ships with was raised tenfold after the book's own bench_loop.mus benchmark (five million loop iterations) tripped the first, smaller guess and was misreported as a false-positive infinite loop (Chapter 18's own Gotcha). If a legitimate script is being killed as runaway, measure its actual instruction count first, don't just raise the cap blindly.

A runtime_error call site does something unsafe immediately after reporting the error. From Chapter 18 onward, runtime_error no longer ends the process; it sets vm.status = .Error and returns normally. Every call site's own code after that call now genuinely executes unless it explicitly returns. Two real instances of this bug shipped in early drafts: native_on (vm.odin) checked its own argument count, called runtime_error, and then read args[1] unconditionally on the very next line, an outright out-of-bounds panic on a malformed call; and a stub host's native_spawn/native_move_to fell through into spawning an entity anyway, with whatever zero-valued arguments happened to be present. Every native builtin, and every opcode case in vm_step, needs an explicit early return after a runtime_error call unless what follows is genuinely safe to run on an about-to-be-discarded stack (Chapter 18's own Gotcha).

A script that allocates structs or arrays in a loop eventually fails with "arena exhausted" instead of running to completion, or worse, corrupts memory silently. Check that Make_Struct/ Make_Array (muspel/vm.odin) are actually checking their own allocation's result before using it: Odin's make/new, given an allocator that's run out of room, don't panic, they return a nil-backed or shorter-than-requested slice (or a nil pointer for new), which is easy to index into or dereference without ever checking, silently reading or writing past the arena's own backing buffer. len(values) != len(layout.field_names) (structs) or != n (arrays), or inst == nil, is the tell; skipping that check is the difference between a clean runtime_error and genuinely undefined behaviour (Chapter 18's own arena-budget section).

Fault isolation and multi-instance hosting

A stress-test or multi-instance harness hangs forever once one instance fails. vm_resume is a no-op on an already-.Error VM, meaning it keeps returning .Error on every future call, not .Done. A driving loop's own "is anything still running" check must exclude both .Done and .Error, not just .Done; excluding only .Done means one failed instance is counted as "still running" forever, and the whole harness never terminates (tools/musc/instances_cmd.odin, section 18.7's own Gotcha).

Modules

Two files importing each other crashes with a stack overflow instead of a clean cycle error. Checking only the completed-modules map (modules[path], muspel/modules.odin) before recursing into an import isn't enough: that map is only written after a file's own imports have all been attempted, so a module still being loaded, somewhere further up the same call stack, isn't in it yet. A second set, loading, marked before recursing into a module's own imports and cleared only after they've all been attempted, is what actually answers "is this file still being loaded right now"; build_module_order's own visiting set does the identical job for the later graph-ordering pass, for the identical reason (Chapter 20's own Gotcha, and a real exit code 139 this book's own a.mus/b.mus test case produced before the fix).

A qualified reference (common.Vec2, common.Phase.Attacking, common.lerp(...)) resolves as an ordinary undefined-variable error instead of finding the import. Confirm the import actually resolved first (resolve_module_graph skips copying a dependency's tables in if that dependency's own path never made it into resolvers, silently, rather than erroring loudly); a missing or misspelled import path fails at the file-loading stage, reported once, and every qualified reference through that alias then fails downstream as a much less obvious "undefined" error. Check the loader's own reported error first, before suspecting the resolver.

A function exported from an imported module resolves fine but calling it fails at run time with an undefined global. compile_module's own qualified re-export (Get_Global "name"; Define_Global "alias.name") only runs for the alias canonical_aliases actually picked for that module, the first alias any importer used for it, scanning in dependency order; a second importer that chose a different alias for the same file gets a resolver-level qualified table that matches what it asked for, but the compiled bytecode was only ever bound under the first alias. This is a named, deliberate scope cut (section 20.6's own "canonical alias" callout), not a bug to chase: give every importer of a shared module the same alias, by convention, and it never comes up.

Live reload (native and browser)

*A hot-swapped chunk's own string constants read back as garbage, or read back as a different script's bytes.* Every string constant a compiled Chunk holds (identifiers, string literals) is a slice straight into whatever source buffer produced it, never a copy, all the way back to the lexer's own tokens. Freeing that buffer once the chunk has been compiled, the way every earlier one-shot musc command safely does, is a genuine use-after-free the moment the chunk outlives the call that compiled it: exactly Watched_Script.chunk's situation (Chapter 19) and Embers' own browser-side reload buffer (Chapter 21) alike. Neither ever frees its own source buffer; both leak it deliberately, one buffer per successful compile, for the life of the process, the same trade-off the tree-walker's own Environment allocation already made back in Chapter 8.

Editing a live script and saving does nothing, and the reload log never updates (or updates with an old version). Confirm the server is actually serving the file being edited, not a frozen copy made at build time. Embers' own build.sh copies batch.js/index.html/scripts/ into dist/ as ordinary files, correct for deployment since dist/ then needs nothing outside itself, but wrong for local live reload on its own: a one-time copy of scripts/ means every future edit to the real source changes nothing the running game can ever see, no error, no visible sign anything is wrong short of a script that stubbornly never updates. The fix lives in the dev server, not the build script: embers/server/serve.odin serves /scripts/* from the project's own scripts/ directory directly (its SCRIPTS_ROOT constant), bypassing dist/scripts entirely, so an edit is visible on the very next poll with no rebuild and no symlink involved (Chapter 21's own Gotcha, and why a symlink was tried first and abandoned once it broke on a real deploy).

A browser-hosted live-reload fetch always 404s, even though the file clearly exists on disk. Check whether the fetch URL carries a cache-busting query string (?t=<timestamp>, needed so the browser never serves a stale .mus file from its own HTTP cache) and whether the dev server actually strips it before doing a file lookup. A server that doesn't is looking for a file literally named wave_01.mus?t=1785332624744, which is never a real file on disk (Chapter 21's own Gotcha); this has no equivalent on the native side, where musc run --watch reads the file directly through core:os and no HTTP request, browser cache, or query string is ever involved.

A WASM build behaves correctly under odin check and a clean odin build, but crashes or misbehaves the instant it actually runs in a browser. Two shapes of this are worth checking first. If start-up-order-dependent state (a package-level table filled in by an @(init) proc) comes back empty or zeroed: @(init) procs are invoked by runtime._startup_runtime, itself only reachable through the WASM _start entry point; a build compiled with -no-entry-point (as every WASM game in this monorepo is, since JS drives the module through its own exported procs instead) skips _start entirely, and @(init) silently never runs at all, correct-looking on native and broken only on -target:js_wasm32 -no-entry-point specifically. The fix is calling the initialisation explicitly from whatever proc JS calls first (batch_main), never relying on @(init) for anything a -no-entry-point WASM build depends on (Chapter 21's own Gotcha). Second, more generally: odin check and a successful build only prove the code compiles, not that it behaves correctly once actually loaded; several real, otherwise-invisible bugs in this book's own Embers were found only by loading the real page in a real browser and reading the real console, never by anything a type-checker could catch.

A general debugging strategy

Every one of the traps above was found the same way: by actually running the code and reading what it actually printed, never by reasoning about what it should do from the source alone. musc debug (Chapter 14) turns "what is the VM actually doing" into a question with a directly observable answer, one instruction at a time; musc compile's own resolved-jump-target column turns "does this jump land where I think it does" into the same kind of directly checkable fact. When something behaves unexpectedly, reach for one of those two before reaching for a guess.