EXAMPLE 08 • image-filter.cpp

WebAssembly: Debugging C++ via Scopes & the Wasm Debug Proxy

A hand-assembled wasm module with a scopes source map: inlined C++ helpers, register reuse, structs in linear memory, i64 values and a wasm trap, all shown as authored C++.

Wasm Source Map Positions (line 0, column = module byte offset)Binding Expressions over V8's Wasm Debug Proxy ($var0, memories[0], stack[0], $func)Struct Reconstruction from Linear Memory (DataView / Uint8Array)SubRangeBinding[] for a Reused Wasm Local ($var3 -> 4 values)3-Level Inlining + 2 Call Sites of clampLevel (callSite)Hidden Compiler Stub (legalstub$adjustImage, isHidden: true)Nested Block Scopes & Strength-Reduced Loop Index (i -> $var4 - $var3)
OPEN CHROME DEVTOOLS (F12) FIRST, THEN TRIGGER:
Ready. Click "Run & Pause in Debugger" with DevTools open.

How This Example Works

Nothing in the scopes proposal is JavaScript-specific. Source maps for WebAssembly use a single generated line (0) and the module byte offset as the column, and binding expressions are evaluated with Debugger.evaluateOnCallFrame on the wasm frame. There, V8 exposes a debug proxy with the wasm state: locals $var0… as { type, value }, the linear memory memories[0] (or $memory, named after its export), functions such as $_Z11adjustPixeliii, and the value stack stack[0]…

image-filter.wasm is a hand-assembled module whose authored source is image-filter.cpp. The bindings describe what an optimizing C++ compiler did: clampLevel and applyContrast are inlined into adjustPixel, with clampLevel inlined at two call sites. One wasm local ($var3) holds four different C++ values over time. The loop index i exists only as a pointer difference, and Image* is decoded from linear memory into an object with a live Uint8Array of pixels. The int64_t checksum shows up as a BigInt, and an Emscripten legalstub$adjustImage wrapper is hidden. The JS glue (bundle.js) is not source-mapped.

This is the experimental example: it only works if DevTools applies scopes to wasm call frames, so expect the most rough edges here.

What to Try & Verify in DevTools

Call Stack

Try:Click "Run & Pause in Debugger". DevTools pauses at debugger; in the JS glue (bundle.js), which implements emscripten_debugger(). Press Step out (Shift+F11) once.

  • Authored location: Paused on line 49 of image-filter.cpp (if (i == 2) emscripten_debugger();), not in the wasm disassembly.
  • Call Stack: adjustImage (image-filter.cpp) → callAdjustImage → window.runExample08 (bundle.js). The Emscripten wrapper legalstub$adjustImage between them is not shown (hidden range).
  • No raw names: No $var… locals and no Expression (value stack) scope from raw wasm debugging.
Scope View

Try:Look at the Scope pane for the adjustImage frame, and expand image and image.pixels.

  • Block scopes: pixel: 2066 (an address) and adjusted: 255; in the enclosing for block, i: 2 (computed as $var4 - $var3 because the compiler turned i into a pointer).
  • Function scope: brightness: 30, contrastPct: 150, pixelCount: 4, checksum: 457n (an int64_t shows up as a BigInt).
  • Struct from linear memory: image is {width: 2, height: 2, pixels: Uint8Array(4) [41, 161, 255, 250]}. The first 3 pixels are already adjusted.
  • Module scope: kMaxLevel: 255 (constant), g_imagesProcessed: 0 and g_lastAverage: 0 (read from linear memory; higher on later runs), clampLevel/applyContrast/averageLevel <unavailable> (always inlined), adjustPixel/adjustImage are functions.
Conditional Breakpoints

Try:Set a conditional breakpoint on line 19 (if (level > kMaxLevel) ... in clampLevel) with condition level > kMaxLevel, then resume (F8). Resume once more after the first hit.

  • 1st hit (call site 1): Call Stack clampLevel → adjustPixel (line 31) → adjustImage (line 46). level: 280 (250 + 30).
  • 2nd hit (call site 2): clampLevel → applyContrast (line 26) → adjustPixel (line 32) → adjustImage. level: 318.
  • Mixed frames: clampLevel and applyContrast are virtual (inlined) frames, while adjustPixel and adjustImage are two real wasm frames.
Scope View

Try:At the 2nd hit, select the applyContrast frame and then the adjustPixel frame in the Call Stack.

  • applyContrast: level: 255, contrastPct: 150, centered: 127, scaled: 190. The wasm local $var3 currently holds scaled + 128, so level and centered are reconstructed from $var0/$var1.
  • adjustPixel: level: 250, brightness: 30, contrastPct: 150, brightened: 255 (reconstructed), result: <unavailable>.
  • Value stack binding: Remove the breakpoints, set one on line 33 (return result;) and resume. result should be 255, read from the wasm value stack (stack[0]).
Logical Stepping

Try:Remove all breakpoints, click Run again, step out of the JS glue and use Step over (F10) until you reach line 46. Then use Step into (F11) repeatedly.

  • Step into enters adjustPixel (a real wasm call) and then the inlined clampLevel/applyContrast frames, which appear and disappear in the Call Stack.
  • Step over on a line that calls an inlined helper (e.g. int32_t brightened = ...) does not stop inside the helper.
  • Step out of an inlined frame lands back in the caller's line.
Error Stack Traces

Try:Remove all breakpoints, resume, and click Trap: Divide by Zero. It runs adjustImage on an empty 0×0 image, so the inlined averageLevel divides by zero.

  • Console: RuntimeError: divide by zero with stack averageLevel (line 37) → adjustImage (line 51) → callAdjustImage → window.logStackExample08 (bundle.js).
  • Hidden and raw frames: legalstub$adjustImage is not shown. The page readout shows the raw err.stack with wasm-function[3]:0x… frames.
  • Pause on exceptions: Enable Pause on caught exceptions and click the button again. DevTools pauses on line 37 with sum: 0n and count: 0. In adjustImage, image is {width: 0, height: 0, pixels: Uint8Array(0)}.
Other things to try
  • Evaluate raw wasm names like $var3, $memory, memories, locals or stack in the Console. Do they still work next to the authored names?
  • Hover image, checksum or pixel in image-filter.cpp for popovers, and check the inline value hints.
  • Right-click the Call Stack › Copy stack trace while paused in the inlined clampLevel.
  • Set a logpoint on line 48 logging i, adjusted, checksum.
  • Open image-filter.wasm itself in the Sources panel (disassembly) and compare the byte offsets with tab 2.
  • Add image as a Watch expression and step through the loop to watch image.pixels change.

Not sure if it's a DevTools bug? Tab 4. Decoded Scopes shows what the source map actually encodes.

Try in the Console While Paused

While paused, select a frame and evaluate these in the Console (Esc opens the drawer) using the authored names:

image.pixels[i]
Expected Result: 255 (adjustImage frame, first pause)
image is decoded from linear memory with a live Uint8Array for pixels, and i is $var4.value - $var3.value. Close to C++'s image->pixels[i].
Number(checksum) / (i + 1)
Expected Result: 152.33333333333334 (adjustImage frame, first pause)
checksum is an int64_t (wasm i64), so its binding produces a BigInt (457n) that has to be converted before mixing it with numbers.
adjustPixel(200, brightness, contrastPct)
Expected Result: 255
adjustPixel is bound to the wasm function $_Z11adjustPixeliii, so the Console calls into wasm while paused.
{ level, centered, scaled }
Expected Result: { level: 255, centered: 127, scaled: 190 } (applyContrast frame, 2nd conditional breakpoint hit)
All three values come from different sub-range bindings: a reconstruction from $var0/$var1 and arithmetic on the reused $var3.