From 341041307664b60641dd1ce99556092a242c45be Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Jul 2026 17:25:47 -0400 Subject: [PATCH] bugc: emit local variables in the debug data Emit a `variables` context at each instruction, and at each block's entry, naming the locals in scope, each with its type and, where its value is located, a pointer to the bytes that hold it: in memory, or on the stack. Each declaration (a parameter, or a `let`) is in scope over its source interval; where several declarations of a name are in scope, the innermost is listed, and it hides a storage variable of that name. A pointer comes only from the listed declaration's own versions. irgen records each version's declaration (its scope, and where it is declared, so two `let`s of a name in one scope differ) and each parameter's location. A `for` loop's init declarations are in scope over the loop only, without a scope of their own, so the code does not change. The pointer names exactly the value's bytes. bugc keeps every scalar right-aligned in its word (bytesN included), so a scalar of `size` bytes is the word's low-order `size` bytes, in a memory word or a stack slot. For a dynamic array, string or bytes local, the word holds the address of its data: the pointer names the word, then `$read`s it for a length word and a `list` of element words (an element that is itself a reference composes the same way) or a data region sized by the length. bugc cannot build structs or fixed-size arrays in memory yet, so those stay type-only. The frame pointer's region is named `-frame`, which no BUG identifier can be. Contexts are postconditions, so a local's memory pointer is listed only from the MSTORE that stores its value, and its stack pointer only once code generation has the value on the stack. Stack pointers are resolved as instructions are emitted, from the stack code generation tracks. Branches, jumps, phi copies and block entries carry the snapshot too; while a jump copies phi values, a local whose home a copy overwrites has no pointer. The snapshot holds under optimization. Before optimizing, bugc records where each instruction and version is, with each function's dominator tree; function clones keep the variable metadata, and inlining carries the callee's locals (with the temps that now hold their values) and marks inlined code with its inline site. At an instruction, the current version is decided where the instruction was before optimization, among all versions: if that version was folded or removed, the local has no pointer. The format has no way yet to say "the value is this expression" (#291). Debug info does not change the generated code. A version that aliases another value's temp (`let b = a;`, `x = c;`) keeps the temp, as before, and records its own definition position, between instructions, apart from the temp's. Variable metadata is keyed by version, so two versions can share a temp. The metadata and origins are kept where the optimizer's fixpoint check, which compares modules as JSON, does not see them. A test compiles every example at every level with and without the tracking and compares the bytecode. A local initialized with a string literal now holds the string; before, it held an empty allocation. Constant propagation no longer propagates string constants, which are data in memory, not operand values. A function's return sequence stops listing its locals once it restores the caller's frame pointer. --- packages/bugc/CHANGELOG.md | 14 + .../debug/local-variables.bytecode.test.ts | 82 ++ .../debug/local-variables.soundness.test.ts | 812 ++++++++++++++++++ .../src/evmgen/debug/local-variables.test.ts | 187 ++++ .../bugc/src/evmgen/debug/local-variables.ts | 719 ++++++++++++++++ packages/bugc/src/evmgen/generation/block.ts | 54 +- .../evmgen/generation/bracket-activation.ts | 14 +- .../generation/control-flow/terminator.ts | 99 ++- .../src/evmgen/generation/values/identify.ts | 7 + .../src/evmgen/generation/values/store.ts | 10 +- packages/bugc/src/evmgen/pass.ts | 7 + packages/bugc/src/evmgen/split-edges.ts | 6 +- packages/bugc/src/evmgen/state.ts | 8 +- packages/bugc/src/ir/spec/block.ts | 7 +- packages/bugc/src/ir/spec/function.ts | 59 ++ packages/bugc/src/ir/spec/instruction.ts | 25 + packages/bugc/src/ir/utils/debug-info.ts | 25 + packages/bugc/src/ir/utils/debug.ts | 30 +- packages/bugc/src/ir/utils/def-sites.ts | 63 ++ packages/bugc/src/ir/utils/index.ts | 2 + packages/bugc/src/irgen/generate/function.ts | 36 +- packages/bugc/src/irgen/generate/module.ts | 1 + packages/bugc/src/irgen/generate/process.ts | 112 ++- packages/bugc/src/irgen/generate/state.ts | 16 +- .../src/irgen/generate/statements/block.ts | 2 +- .../irgen/generate/statements/control-flow.ts | 6 +- .../src/irgen/generate/statements/declare.ts | 26 +- packages/bugc/src/optimizer/optimizer.ts | 7 +- packages/bugc/src/optimizer/origins.ts | 48 ++ .../bugc/src/optimizer/simple-optimizer.ts | 2 + .../optimizer/steps/constant-propagation.ts | 8 +- packages/bugc/src/optimizer/steps/inlining.ts | 67 +- packages/bugc/test/evm/locals.ts | 400 +++++++++ packages/bugc/test/examples/examples.test.ts | 74 +- .../compiler/case-study-bug.mdx | 9 +- 35 files changed, 2933 insertions(+), 111 deletions(-) create mode 100644 packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.test.ts create mode 100644 packages/bugc/src/evmgen/debug/local-variables.ts create mode 100644 packages/bugc/src/ir/utils/debug-info.ts create mode 100644 packages/bugc/src/ir/utils/def-sites.ts create mode 100644 packages/bugc/src/optimizer/origins.ts create mode 100644 packages/bugc/test/evm/locals.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 3fc4c7d36d..baa50493ee 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -11,6 +11,16 @@ support. Changes to the specification itself are tracked in the root - The `%` operator, with the precedence of `*` and `/`. It compiles to the EVM's unsigned `MOD`, so `x % 0` is `0`, as `x / 0` is ([#321]). +- Each instruction has a `variables` context that lists the local + variables in scope (the innermost declaration of each name, which hides + a storage variable of that name), with a type, and a pointer to the + exact bytes that hold the value, in memory or on the stack, where the + value is there. For a dynamic array, `string` or `bytes` local, the + pointer reads the local's word and goes on to the length and the + elements or data it refers to. At optimization levels 1 to 3, a local + whose current value the optimizer folded to a constant or removed is + listed without a pointer, and an inlined function's locals are listed + in its inlined code ([#328]). ### Changed @@ -108,6 +118,9 @@ support. Changes to the specification itself are tracked in the root - At optimization level 3, two writes in a row to the same field of a packed struct now leave the second value. Before, read/write merging combined the two values with `or` ([#337]). +- A local initialized with a string literal (`let s = "hello";`) now + holds the string. Before, it held an empty allocation, so `s.length` + was `0` ([#328]). ## 0.1.0-preview.1 — 2026-10-03 @@ -149,6 +162,7 @@ First publication. [#320]: https://github.com/ethdebug/format/pull/320 [#321]: https://github.com/ethdebug/format/pull/321 [#327]: https://github.com/ethdebug/format/pull/327 +[#328]: https://github.com/ethdebug/format/pull/328 [#330]: https://github.com/ethdebug/format/pull/330 [#332]: https://github.com/ethdebug/format/pull/332 [#333]: https://github.com/ethdebug/format/pull/333 diff --git a/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts b/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts new file mode 100644 index 0000000000..7d983a5241 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.bytecode.test.ts @@ -0,0 +1,82 @@ +/** + * Debug info must not change the generated code: compiling every + * example at every level gives the same bytecode with local-variable + * tracking (recording origins, enriching instructions) as without it. + */ +import { it, expect, vi } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { bytesToHex } from "ethereum-cryptography/utils"; + +const examples = join(__dirname, "../../../examples"); + +function sources(dir = examples): Array<[string, string]> { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sources(path); + return entry.name.endsWith(".bug") + ? [[path.slice(examples.length + 1), readFileSync(path, "utf8")]] + : []; + }) as Array<[string, string]>; +} + +type Compile = (typeof import("#compiler"))["compile"]; + +async function bytecode(compile: Compile): Promise> { + const out: Record = {}; + for (const [name, source] of sources()) { + for (const level of [0, 1, 2, 3] as const) { + let result: string; + try { + const compiled = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + result = compiled.success + ? bytesToHex(compiled.value.bytecode.runtime) + + "/" + + bytesToHex(compiled.value.bytecode.create ?? new Uint8Array()) + : "fails"; + } catch { + result = "throws"; + } + out[`${name} O${level}`] = result; + } + } + return out; +} + +it("local-variable tracking does not change any bytecode", async () => { + const tracked = await bytecode((await import("#compiler")).compile); + + vi.resetModules(); + vi.doMock( + "../../../dist/src/evmgen/debug/local-variables.js", + async (load) => ({ + ...(await load()), + enrich: (module: unknown) => module, + }), + ); + vi.doMock("../../../dist/src/optimizer/origins.js", () => ({ + recordOrigins: () => {}, + })); + const { compile } = await import("#compiler"); + + // Without tracking, no instruction lists a local + const probe = await compile({ + to: "bytecode", + source: `name P; storage { [0] r: uint256; } create {} +code { let x = 7; r = x; }`, + optimizer: { level: 0 }, + }); + if (!probe.success) throw new Error("compile failed"); + const listed = probe.value.bytecode.runtimeProgram.instructions.some((i) => + ( + i.context as { variables?: Array<{ identifier: string }> } + )?.variables?.some((v) => v.identifier === "x"), + ); + expect(listed).toBe(false); + + expect(await bytecode(compile)).toEqual(tracked); +}, 120_000); diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts new file mode 100644 index 0000000000..6e4a1e23fe --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -0,0 +1,812 @@ +/** + * Soundness of the per-instruction local-variable snapshot. + * + * The structural tests check which locals are listed where: never + * before their declaration, once per name, within their scope, and at + * calls. The program tests run small programs at every optimization + * level and, at every step that lists a local with a pointer, read it + * against the machine state after the step: it must hold the value the + * program gives that local there (see `check`). + */ +import { describe, it, expect } from "vitest"; + +import { Program } from "@ethdebug/format"; +import { executeProgram } from "#test/evm/behavioral"; +import { + type Level, + type LocalsProgram, + type Shape, + check, + localsOf, + textBytes, + traceLocals, +} from "#test/evm/locals"; + +const levels: Level[] = [0, 1, 2, 3]; + +/** The names listed at each instruction of a program */ +async function listed(source: string, level: Level = 0) { + const { program } = await traceLocals(source, level); + return program.instructions.map((instruction) => ({ + instruction, + names: localsOf(instruction.context).map((v) => v.identifier as string), + })); +} + +/** The offset of an instruction's `code`, if it has one */ +function codeOffset(context: unknown): number | undefined { + return (context as { code?: { range?: { offset: number } } } | undefined) + ?.code?.range?.offset; +} + +describe("which locals are listed", () => { + it("(a) does not list a local before its declaration", async () => { + const source = `name PreDef; +define { + function f(n: uint256) -> uint256 { + let y = n * 2; + let x = y + 1; + if (n > 0) { return x; } + else { return x + y; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(3); }`; + const declared = source.indexOf("let x"); + const rows = await listed(source); + for (const { instruction, names } of rows) { + const offset = codeOffset(instruction.context); + if (offset !== undefined && offset < declared) { + expect(names, `x listed at offset ${offset}`).not.toContain("x"); + } + } + expect(rows.some(({ names }) => names.includes("x"))).toBe(true); + }); + + it("(b) lists every local in scope, with a type", async () => { + // `s` is used only within one block, so it stays on the stack; + // it is listed all the same. + const source = `name Complete; +define { + function h(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = h(3, 4); }`; + const { program } = await traceLocals(source); + const entries = program.instructions.flatMap((i) => localsOf(i.context)); + for (const name of ["a", "b", "s"]) { + const named = entries.filter((v) => v.identifier === name); + expect(named.length, name).toBeGreaterThan(0); + expect(named.some((v) => v.pointer)).toBe(true); + } + for (const entry of entries) expect(entry.type).toBeDefined(); + }); + + it("(c) lists each name at most once per instruction", async () => { + const source = `name Shadow; +define { + function sh(n: uint256) -> uint256 { + let x = 111; + if (n > 0) { + let x = 222; + return x; + } + return x; + }; +} +storage { [0] r: uint256; [1] x: uint256; } +create {} +code { r = sh(0); }`; + const { getStorage } = await executeProgram(source, { calldata: "" }); + expect(await getStorage(0n)).toBe(111n); + for (const { names } of await listed(source)) { + expect(new Set(names).size, `[${names}]`).toBe(names.length); + } + }); + + it("(d) keeps locals listed at a call", async () => { + const source = `name AtCall; +define { + function add(x: uint256, y: uint256) -> uint256 { return x + y; }; +} +storage { [0] r: uint256; } +create {} +code { + let a = 3; + let b = 4; + let c = 5; + r = add(a, b); + r = r + c; +}`; + const rows = await listed(source); + const jump = rows.find( + ({ instruction }) => + instruction.operation?.mnemonic === "JUMP" && + instruction.context !== undefined && + Program.Context.isInvoke(instruction.context), + ); + expect(jump, "invoke JUMP for add").toBeDefined(); + for (const name of ["a", "b", "c"]) expect(jump!.names).toContain(name); + }); + + it("(e) lists a for loop's variable only within the loop", async () => { + const source = `name LoopScope; +storage { [0] r: uint256; } +create {} +code { + let acc = 0; + for (let i = 0; i < 3; i = i + 1) { + acc = acc + i; + } + r = acc; +}`; + const after = source.indexOf(" r = acc;"); + const rows = await listed(source); + for (const { instruction, names } of rows) { + const offset = codeOffset(instruction.context); + if (offset !== undefined && offset >= after) { + expect(names).not.toContain("i"); + expect(names).toContain("acc"); + } + } + expect(rows.some(({ names }) => names.includes("i"))).toBe(true); + }); + + it("(f) gives a stack-resident local a stack pointer", async () => { + const { program } = await traceLocals(programs[0].source); + const pointers = program.instructions.flatMap((i) => + localsOf(i.context) + .filter((v) => v.identifier === "p" && v.pointer) + .map((v) => (v.pointer as { location?: string }).location), + ); + expect(pointers.length).toBeGreaterThan(0); + expect(new Set(pointers)).toEqual(new Set(["stack"])); + }); + + it.each([2, 3] as const)( + "(g) lists an inlined function's locals in its inlined code at O%i", + async (level) => { + const source = `name Inlined; +define { + function add(a: uint256, b: uint256) -> uint256 { + let sum = a + b; + return sum * 2; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { r = add(s, 2); }`; + const { program } = await traceLocals(source, level); + const transforms = (context: unknown): unknown[] => { + const { transform, gather } = (context ?? {}) as { + transform?: unknown[]; + gather?: unknown[]; + }; + return [...(transform ?? []), ...(gather ?? []).flatMap(transforms)]; + }; + const inlined = program.instructions.filter((i) => + localsOf(i.context).some((v) => v.identifier === "sum" && v.pointer), + ); + expect(inlined.length).toBeGreaterThan(0); + for (const i of inlined) { + expect(transforms(i.context)).toContain("inline"); + } + }, + ); +}); + +const words: Shape = { kind: "array", element: { kind: "scalar", size: 32 } }; + +/** A scalar program for each sub-word kind, `ad` passed as `arg` */ +const kinds = ( + [ + ["address", "0x00000000000000000000000000000000000000aA", 20, 0xaan], + ["bool", "true", 1, 1n], + ["uint8", "200", 1, 200n], + ["uint32", "70000", 4, 70000n], + // bugc cannot yet produce a nonzero intN, so read one from storage + ["int8", "s", 1, 0n], + ["bytes4", "0xABCDEF12", 4, 0xabcdef12n], + ["uint256", "51966", 32, 51966n], + ] as const +).map( + ([type, arg, size, value]): LocalsProgram => ({ + name: `sub-word ${type}`, + source: `name Subword; +define { + function g(ad: ${type}, n: uint256) -> uint256 { + if (n > 0) { return n; } + let z = ad; + return 0; + }; +} +storage { [0] r: uint256; [1] s: int8; } +create {} +code { r = g(${arg}, 0); }`, + locals: { + ad: { values: [value], size }, + z: { values: [value], size }, + n: { values: [0n] }, + }, + }), +); + +/** + * Programs and the values their locals take, in execution order. A + * value with `after` may be read only once that statement has run. + */ +const programs: LocalsProgram[] = [ + { + name: "straight line", + source: `name Straight; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let n = 4; + let p = n + s; + r = p * n; +}`, + locals: { n: { values: [4n] }, p: { values: [9n] } }, + }, + { + name: "store timing", + source: `name StoreTiming; +define { + function f(a: uint256) -> uint256 { + let n = a + 3; + if (a > 0) { return n; } + return n + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(4); }`, + locals: { a: { values: [4n] }, n: { values: [7n] } }, + }, + { + name: "one-armed if", + source: `name OneArmed; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 1; } +code { + let x = 7; + if (flag > 0) { x = 9; } + r = x; +}`, + locals: { x: { values: [7n, { value: 9n, after: "x = 9" }] } }, + }, + { + name: "if-else", + source: `name IfElse; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 0; } +code { + let x = 7; + let y = 1; + if (flag > 0) { y = 2; } + else { y = 3; } + x = x * y; + r = x + y; +}`, + locals: { + x: { values: [7n, { value: 21n, after: "x = x * y" }] }, + y: { values: [1n, { value: 3n, after: "y = 3" }] }, + }, + }, + { + name: "if-else reassigning in both arms", + source: `name IfElseBoth; +storage { [0] r: uint256; [1] flag: uint256; } +create { flag = 0; } +code { + let x = 7; + let y = 1; + if (flag > 0) { y = 2; x = x + y; } + else { y = 3; x = x * y; } + r = x + y; +}`, + locals: { + x: { values: [7n, { value: 21n, after: "x = x * y" }] }, + y: { values: [1n, { value: 3n, after: "y = 3" }] }, + }, + }, + { + name: "reassigned in a function", + source: `name Reassign; +define { + function g(n: uint256) -> uint256 { + let x = 111; + let keep = n; + if (n > 0) { x = 222; } + else { x = 333; } + return x + keep; + }; +} +storage { [0] r: uint256; } +create {} +code { r = g(7); }`, + locals: { + n: { values: [7n] }, + keep: { values: [7n] }, + x: { values: [111n, { value: 222n, after: "x = 222" }] }, + }, + }, + { + name: "loop", + source: `name Loop; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 4; } +code { + let acc = 0; + for (let i = 0; i < s; i = i + 1) { + acc = acc + i; + } + r = acc; +}`, + locals: { + acc: { + values: [ + 0n, + ...[0n, 1n, 3n, 6n].map((value) => ({ + value, + after: "acc = acc + i", + })), + ], + }, + i: { + values: [ + 0n, + ...[1n, 2n, 3n, 4n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "a let in a loop shadows a local", + source: `name LoopShadow; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 2; } +code { + let x = 1; + for (let i = 0; i < s; i = i + 1) { + let x = 50 + i; + r = r + x; + } + r = r + x; +}`, + locals: { + "x@x = 1": { values: [1n] }, + "x@x = 50": { + values: [50n, 51n].map((value) => ({ value, after: "x = 50 + i" })), + }, + i: { + values: [ + 0n, + ...[1n, 2n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "a folded version, then a located one", + source: `name Folded; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 9; } +code { + let x = 7; + r = x; + x = s; + r = r + x; +}`, + // At O1 and up, x = 7 is folded into its use; x = s still has a + // value to point to + locals: { + x: { values: [7n, { value: 9n, after: "x = s" }], everyLevel: true }, + }, + }, + { + name: "a loop that swaps two locals", + source: `name Swap; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let a = 1; + let b = 2; + for (let i = 0; i < s; i = i + 1) { + let t = a; + a = b; + b = t; + r = r + t; + } + r = a * 10 + b; +}`, + locals: { + a: { values: [1n, 2n, 1n, 2n] }, + b: { values: [2n, 1n, 2n, 1n] }, + t: { values: [1n, 2n, 1n] }, + i: { + values: [ + 0n, + ...[1n, 2n, 3n].map((value) => ({ value, after: "i = i + 1" })), + ], + }, + }, + }, + { + name: "two lets of one name in one scope", + source: `name Redeclare; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 4; } +code { + let x = s + 1; + r = x; + let x = s + 2; + r = r + x; +}`, + locals: { + "x@x = s + 1": { values: [5n] }, + "x@x = s + 2": { values: [6n] }, + }, + }, + { + name: "a local aliased after the statement that computes it", + source: `name Early; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let y = s; let x = y * 2; y = x; r = y; }`, + locals: { + y: { values: [3n, { value: 6n, after: "r = y" }] }, + x: { values: [6n] }, + }, + }, + { + name: "an inlined function's local aliased after its statement", + source: `name EarlyInline; +define { + function f(y: uint256) -> uint256 { + let x = y * 2; + y = x; + return y + 1; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let y = s; r = f(y); r = r + y; }`, + locals: { + "y@y: uint256": { values: [3n, { value: 6n, after: "return y + 1" }] }, + "y@y = s": { values: [3n] }, + x: { values: [6n] }, + }, + }, + { + name: "locals that alias each other", + source: `name Cp; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { let a = s; let b = a; let c = b; r = c + a; }`, + locals: { + a: { values: [3n] }, + b: { values: [3n] }, + c: { values: [3n] }, + }, + }, + { + name: "a local hides a storage variable", + source: `name Coll; +storage { [0] r: uint256; [1] x: uint256; } +create { x = 5; } +code { let x = 7; r = x; r = r + 1; }`, + locals: { x: { values: [7n] } }, + }, + { + name: "a local beside a storage struct", + source: `name StructStor; +define { struct P { a: uint128; b: uint128; }; } +storage { [0] r: uint256; [1] p: P; } +create { p.b = 3; } +code { let a = 7; r = a + p.b; r = r + a; }`, + locals: { a: { values: [7n] } }, + }, + { + name: "a parameter hides a storage variable", + source: `name ParamStor; +define { + function setOwner(owner: uint256) -> uint256 { + let o = owner; + if (o > 100) { return 0; } + return o + owner; + }; +} +storage { [0] r: uint256; [1] owner: uint256; } +create { owner = 9; } +code { r = setOwner(4); }`, + locals: { owner: { values: [4n] }, o: { values: [4n] } }, + }, + { + name: "a parameter named frame", + source: `name FrameParam; +define { + function f(frame: uint256) -> uint256 { + if (frame > 100) { return 0; } + return frame + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(4); }`, + locals: { frame: { values: [4n] } }, + }, + { + name: "a callee's locals until its frame is torn down", + source: `name Teardown; +define { + function weight(x: uint256, w: uint256) -> uint256 { + return x * w; + }; +} +storage { [0] r: uint256; } +create {} +code { r = weight(21, 2); }`, + locals: { x: { values: [21n] }, w: { values: [2n] } }, + }, + { + name: "calls", + source: `name Calls; +define { + function add(a: uint256, b: uint256) -> uint256 { + let sum = a + b; + return sum; + }; + function twice(c: uint256) -> uint256 { + let d = c + c; + if (c > 100) { return 0; } + return d; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let m = s; + let k = add(m, 2); + r = twice(k) + m; +}`, + locals: { + a: { values: [5n] }, + b: { values: [2n] }, + sum: { values: [7n] }, + c: { values: [7n] }, + d: { values: [14n] }, + m: { values: [5n] }, + k: { values: [7n] }, + }, + }, + { + name: "one-armed if in a function", + source: `name PickFn; +define { + function pick(c: uint256) -> uint256 { + let x = 1; + if (c > 2) { x = c; } + return x; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { r = pick(s); }`, + locals: { + c: { values: [5n] }, + // `x = c` emits no code, so it cannot mark when 5 is due + x: { values: [1n, 5n] }, + }, + }, + { + name: "two inlinable calls", + source: `name TwoCalls; +define { + function inc(v: uint256) -> uint256 { + let w = v + 1; + return w; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 5; } +code { + let p = inc(s); + let q = inc(p + 10); + r = p + q; +}`, + locals: { + v: { values: [5n, 16n] }, + w: { values: [6n, 17n] }, + p: { values: [6n] }, + q: { values: [17n] }, + }, + }, + { + name: "loop calling a function", + source: `name LoopCall; +define { + function sq(u: uint256) -> uint256 { + return u * u; + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { + let total = 0; + for (let j = 1; j <= s; j = j + 1) { + total = total + sq(j); + } + r = total; +}`, + locals: { + total: { values: [0n, 1n, 5n, 14n] }, + j: { values: [1n, 2n, 3n, 4n] }, + u: { values: [1n, 2n, 3n] }, + }, + }, + { + name: "tail recursion", + source: `name TailSum; +define { + function sumTo(n: uint256, acc: uint256) -> uint256 { + if (n == 0) { return acc; } + return sumTo(n - 1, acc + n); + }; +} +storage { [0] r: uint256; [1] s: uint256; } +create { s = 3; } +code { r = sumTo(s, 0); }`, + locals: { + n: { values: [3n, 2n, 1n, 0n] }, + acc: { values: [0n, 3n, 5n, 6n] }, + }, + // A recursive call revisits a caller's values, so recursion fits + // this check only once tail-call optimization makes it a loop + levels: [2, 3], + }, + ...kinds, + { + name: "two sub-word locals in one frame", + source: `name Subwords; +define { + function f(ad: address, flag: bool, m: uint256) -> uint256 { + if (m > 99) { return 1; } + let z = ad; + let g = flag; + return 0; + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(0x00000000000000000000000000000000000000aA, true, 0); }`, + locals: { + ad: { values: [0xaan], size: 20 }, + z: { values: [0xaan], size: 20 }, + flag: { values: [1n], size: 1 }, + g: { values: [1n], size: 1 }, + m: { values: [0n] }, + }, + }, + { + name: "array", + source: `name Arr; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 2; } +code { + let items: array = [42, 12, 99]; + if (s > 1) { r = items[s]; } + r = r + items.length; +}`, + locals: { items: { shape: words, values: [[42n, 12n, 99n]] } }, + }, + { + name: "nested array", + source: `name Nested; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let m: array> = [[1, 2], [3]]; + if (s > 0) { r = m[s][0]; } + r = r + m.length; +}`, + locals: { + m: { + shape: { kind: "array", element: words }, + values: [[[1n, 2n], [3n]]], + }, + }, + }, + { + name: "reassigned array", + source: `name ReassignArr; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let items: array = [5]; + if (s > 0) { items = [7, 8]; } + r = items.length; +}`, + locals: { + items: { + shape: words, + values: [[5n], { value: [7n, 8n], after: "items = [7, 8]" }], + }, + }, + }, + { + name: "strings", + source: `name Strings; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let short = "hello"; + let long = "hello world, this is longer than thirty-two bytes"; + if (s > 0) { r = short.length; } + r = r + long.length; +}`, + locals: { + short: { shape: { kind: "bytes" }, values: [textBytes("hello")] }, + long: { + shape: { kind: "bytes" }, + values: [ + textBytes("hello world, this is longer than thirty-two bytes"), + ], + }, + }, + }, + { + name: "bytes slice", + source: `name Slice; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let b = msg.data[0:4]; + if (s > 0) { r = b.length; } + r = r + b.length; +}`, + locals: { b: { shape: { kind: "bytes" }, values: ["0x00000000"] } }, + }, + { + name: "a uint8 cast", + source: `name U8; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 300; } +code { + let n = s; + let m = n as uint8; + if (s > 0) { r = m; } +}`, + locals: { n: { values: [300n] }, m: { values: [44n], size: 1 } }, + }, + { + // Narrowing casts truncate since #334, so `b` is `h`'s first four + // bytes, right-aligned + name: "a bytes4 cast", + source: `name B4; +storage { [0] r: uint256; [1] s: uint256; } +create { s = 1; } +code { + let h: bytes32 = 0x1122334400000000000000000000000000000000000000000000000000000000; + let b = h as bytes4; + if (b == 0x11223344) { r = 1; } + r = r + 2; +}`, + locals: { + h: { values: [0x11223344n << 224n] }, + b: { values: [0x11223344n], size: 4 }, + }, + }, +]; + +describe.each(levels)("locals hold the program's values at O%i", (level) => { + it.each(programs.filter((p) => !p.levels || p.levels.includes(level)))( + "$name", + (program) => check(program, level), + ); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.test.ts b/packages/bugc/src/evmgen/debug/local-variables.test.ts new file mode 100644 index 0000000000..8cad2b0ec9 --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.test.ts @@ -0,0 +1,187 @@ +/** + * Emission tests for memory-homed local-variable debug info (O0). + * + * Verifies that parameters and cross-block `let` bindings that the + * memory planner spills to frame-relative memory get a `variables` + * context with a frame-relative pointer, and that behavior is + * unaffected. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; + +async function compileProgram( + source: string, + level: 0 | 1 | 2 | 3, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + "compile failed:\n" + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode.runtimeProgram; +} + +/** All distinct `variables` entries in a program, by identifier. */ +function localEntries( + program: Format.Program, +): Map> { + const byId = new Map>(); + for (const instr of program.instructions) { + const ctx = instr.context as Record | undefined; + if (!ctx || !Array.isArray(ctx.variables)) continue; + for (const v of ctx.variables as Array>) { + if (typeof v.identifier !== "string") continue; + const existing = byId.get(v.identifier); + // Prefer the located record: a variable now appears type-only + // (in scope, no value) as well as with a pointer where located. + if (!existing || (!existing.pointer && v.pointer)) { + byId.set(v.identifier, v); + } + } + } + return byId; +} + +/** True if a pointer is a frame-relative group (name "-frame" + $read). */ +function isFrameRelative(pointer: unknown): boolean { + if (!pointer || typeof pointer !== "object") return false; + const group = (pointer as { group?: unknown[] }).group; + if (!Array.isArray(group) || group.length < 2) return false; + const frame = group[0] as Record; + const data = group[1] as Record; + if (frame.name !== "-frame" || frame.location !== "memory") return false; + const offset = data.offset as Record | undefined; + return !!offset && Array.isArray(offset.$sum); +} + +describe("local-variable debug emission (memory-homed, O0)", () => { + describe("function parameters", () => { + const source = `name Params; +define { + function add(a: uint256, b: uint256) -> uint256 { + let s = a + b; + return s; + }; +} +storage { [0] r: uint256; } +create {} +code { r = add(3, 4); }`; + + it("emits frame-relative variables for both params", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + + for (const name of ["a", "b"]) { + const entry = locals.get(name); + expect(entry, `variables entry for ${name}`).toBeDefined(); + expect(entry!.identifier).toBe(name); + expect(isFrameRelative(entry!.pointer)).toBe(true); + expect(entry!.type).toEqual({ kind: "uint", bits: 256 }); + } + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(7n); + }); + }); + + describe("cross-block let binding", () => { + // `x` is defined before the branch and used in both arms, so it + // crosses block boundaries and is spilled to frame memory. + const source = `name CrossBlock; +define { + function f(n: uint256) -> uint256 { + let x = n + 1; + if (n > 0) { return x; } + else { return x + n; } + }; +} +storage { [0] r: uint256; } +create {} +code { r = f(5); }`; + + it("emits a frame-relative variable for the spilled let", async () => { + const program = await compileProgram(source, 0); + const locals = localEntries(program); + const x = locals.get("x"); + expect(x, "variables entry for x").toBeDefined(); + expect(isFrameRelative(x!.pointer)).toBe(true); + }); + + it("keeps runtime behavior correct", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(6n); + }); + }); + + describe("call inside a loop", () => { + // A call in a `for` body to a function whose return expression has + // two operations: each iteration must add weight(i, 7) = 7i + 1, + // so r = 1 + 8 + 15. + const source = `name LoopCall; +define { + function weight(x: uint256, w: uint256) -> uint256 { + return x * w + 1; + }; +} +storage { [0] r: uint256; } +create {} +code { + let acc = 0; + for (let i = 0; i < 3; i = i + 1) { + acc = acc + weight(i, 7); + } + r = acc; +}`; + + // At level 3 this call reverts: a separate optimizer bug. + for (const level of [0, 1, 2] as const) { + it(`keeps runtime behavior correct (level ${level})`, async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(res.callSuccess).toBe(true); + expect(await res.getStorage(0n)).toBe(24n); + }); + } + }); + + describe("contract without functions", () => { + // No user functions, no frames — the pass is a no-op and must + // not disturb compilation. + const source = `name NoFns; +storage { [0] r: uint256; } +create { r = 0; } +code { r = r + 1; }`; + + it("compiles and runs", async () => { + const res = await executeProgram(source, { + calldata: "", + optimizationLevel: 0, + }); + expect(res.callSuccess).toBe(true); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts new file mode 100644 index 0000000000..7712dec07d --- /dev/null +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -0,0 +1,719 @@ +/** + * Local-variable debug info: a per-instruction snapshot of the locals + * in scope, on two independent axes. + * + * - LIST (which variables appear) = LEXICAL SCOPE. Each declaration + * (a parameter, or a `let`) is in scope over its source interval: a + * `let` from its declaration to the end of its declaring scope, a + * parameter throughout its function. Where several declarations of + * one name are in scope, the innermost one is listed, and it hides + * a storage variable of the same name. Stamped on every + * instruction and terminator, and on each block's entry, except the + * return sequence's frame teardown (`withoutFrameLocals`). Inlined + * code lists only the inlined callee's locals (see + * `Instruction.Debug.inlineSites`). + * - POINTER (value vs type-only) = AVAILABILITY. A pointer is given + * only from the listed declaration's own versions: the current one + * (the deepest whose definition dominates the instruction), where + * its value is in memory (its home) or, during code generation, on + * the stack (`withStackLocals`). Contexts are postconditions, so an + * instruction that defines a memory-homed version lists it only on + * the MSTORE that homes it. + * + * Optimization can fold a version to a constant or remove it. The + * current version is then decided where the instruction was before + * optimization (its `origin`), among all versions: where that is a + * removed version, the local is listed without a pointer, never with + * a stale value. The format has no form yet for "the value is this + * expression" (ethdebug/format#291), so a folded value cannot be + * stated either. + * + * A reference-typed local (memory array, string, dynamic bytes) is + * held as an address word; its pointer names that word, then the data + * it refers to (`dataPointers`). + * + * The memory pointer encodes bugc's frame convention: the frame base + * lives in memory at `FRAME_POINTER` (0x80); a frame-homed local is at + * `mem[ mem[FRAME_POINTER] + delta ]`, expressed as a `group` naming + * the frame region `-frame` (no BUG identifier starts with `-`) and a + * data region whose offset reads it (`$read`) and adds the static + * delta (`$sum`). Functions without a frame (main/create) home locals + * at a static memory offset. + */ +import type * as Format from "@ethdebug/format"; + +import * as Ir from "#ir"; +import { Type as BugType } from "#types"; +import { Memory } from "#evmgen/analysis"; +import { convertToEthDebugType } from "../../irgen/debug/types.js"; +import { fromBugType } from "../../irgen/type.js"; + +type VariableEntry = Format.Program.Context.Variables["variables"][number]; + +/** The frame pointer's region; not a BUG identifier, so no local's */ +const FRAME = "-frame"; +type StackLocal = NonNullable[number]; + +type DefSite = Ir.Utils.DefSite; + +/** + * A value is homed by a full-word `MSTORE` of its stack word, which + * writes the word big-endian. bugc keeps every scalar right-aligned on + * the stack (`bytesN` included), so a scalar of `size` bytes is the + * word's low-order `size` bytes: `32 - size` bytes into the word, + * `size` bytes long. The same holds for a stack slot (segment offsets + * count from the word's most significant byte). + */ +function memoryPointer( + identifier: string, + size: number, + allocation: Memory.Allocation, + frameSize: number | undefined, +): Format.Pointer { + const offset = Number(allocation.offset) + 32 - size; + + if (frameSize === undefined) { + // No call frame (main/create): static memory offset. + return { name: identifier, location: "memory", offset, length: size }; + } + + // Frame-relative: mem[ mem[FRAME_POINTER] + delta ]. + return { + group: [ + { + name: FRAME, + location: "memory", + offset: Memory.regions.FRAME_POINTER, + length: 32, + }, + { + name: identifier, + location: "memory", + offset: { $sum: [{ $read: FRAME }, offset] }, + length: size, + }, + ], + }; +} + +function stackPointer( + identifier: string, + slot: number, + size: number, +): Format.Pointer { + return { + name: identifier, + location: "stack", + slot, + ...(size < 32 ? { offset: 32 - size, length: size } : {}), + }; +} + +/** + * Pointers to the data a reference at `base` refers to, by bugc's + * memory layout, or undefined if the layout is not described here. + * + * - `string` and dynamic `bytes`: a length word at `base`, then that + * many bytes of data. + * - dynamic array: a length word at `base`, then one word per element + * (`base + 32 + 32 * i`). A scalar element is right-aligned in its + * word; a reference element's word holds the address of its data, + * described the same way. + * + * Regions are named after the local: `x-length`, `x-data`, + * `x-element`, and for a nested reference `x-element-length` and so + * on. Fixed-size arrays and structs are left out: bugc cannot build + * one in memory yet (no struct literal; array literals are dynamic). + */ +function dataPointers( + base: Format.Pointer.Expression, + type: Ir.Type.Origin, + name: string, + depth: number = 0, +): Format.Pointer[] | undefined { + if (type === "synthetic") return undefined; + const length: Format.Pointer = { + name: `${name}-length`, + location: "memory", + offset: base, + length: 32, + }; + + if ( + BugType.isElementary(type) && + (BugType.Elementary.isString(type) || + (BugType.Elementary.isBytes(type) && type.size === undefined)) + ) { + return [ + length, + { + name: `${name}-data`, + location: "memory", + offset: { $sum: [base, 32] }, + length: { $read: `${name}-length` }, + }, + ]; + } + + if (!BugType.isArray(type) || type.size !== undefined) return undefined; + + const index = `i${depth}`; + const element = `${name}-element`; + const wordOffset = (within: number): Format.Pointer.Expression => ({ + $sum: [base, 32 + within, { $product: [index, 32] }], + }); + const elementType = fromBugType(type.element); + let is: Format.Pointer; + if (Ir.Type.isScalar(elementType)) { + const size = elementType.size; + is = { + name: element, + location: "memory", + offset: wordOffset(32 - size), + length: size, + }; + } else { + const nested = dataPointers( + { $read: element }, + type.element, + element, + depth + 1, + ); + if (!nested) return undefined; + is = { + group: [ + { + name: element, + location: "memory", + offset: wordOffset(0), + length: 32, + }, + ...nested, + ], + }; + } + + return [ + length, + { list: { count: { $read: `${name}-length` }, each: index, is } }, + ]; +} + +/** A pointer to a local's word plus, for a reference, its data. */ +function withData( + word: Format.Pointer, + data: Format.Pointer[] | undefined, +): Format.Pointer { + if (!data) return word; + return "group" in word + ? { group: [...word.group, ...data] } + : { group: [word, ...data] }; +} + +/** + * One declaration of a local: its versions, and the source interval + * over which it is in scope. + */ +interface Declaration { + name: string; + /** The inline site whose code lists it (undefined: the function's) */ + site: string | undefined; + versions: Ir.Function.SsaVariable[]; + /** Where the declaration is, for the entry's `declaration` */ + loc: Ir.Function.SsaVariable["loc"]; + start: number; + end: number; +} + +/** Group a function's versions by declaration. */ +function declarations(func: Ir.Function): Declaration[] { + const groups = new Map(); + for (const ssa of func.ssaVariables!.values()) { + const key = [ssa.inlineSite ?? "", ssa.name, ssa.declaredIn ?? ""].join( + "/", + ); + const list = groups.get(key); + if (list) list.push(ssa); + else groups.set(key, [ssa]); + } + return [...groups.values()].map((versions) => { + const { name, inlineSite, declaredIn, scopeEnd } = versions[0]; + const loc = versions.find((v) => v.loc)?.loc; + // A parameter is in scope throughout its function; a `let` from + // its declaration to the end of the scope that declares it. + const [scopeStart] = (declaredIn ?? "").split(":").map(Number); + const param = scopeEnd === undefined; + return { + name, + site: inlineSite, + versions, + loc, + start: param + ? -Infinity + : loc + ? Number(loc.offset) + : (scopeStart ?? -Infinity), + end: param ? Infinity : scopeEnd, + }; + }); +} + +/** Identifier + type + declaration of a local. */ +function buildEntry( + declaration: Declaration, + type: Ir.Type, + sourceId: string, +): VariableEntry { + const entry: VariableEntry = { identifier: declaration.name }; + + const converted = convertToEthDebugType(type); + if (converted) entry.type = converted; + + if (declaration.loc) { + entry.declaration = { + source: { id: sourceId }, + range: declaration.loc, + }; + } + + return entry; +} + +/** + * Merge variable entries into an instruction's debug context as a + * flat `variables` sibling, alongside any existing `variables` (the + * storage variables from irgen) and preserving all other keys. A local + * in scope hides a storage variable of the same name. + */ +function withVariables( + debug: Ir.Instruction.Debug | undefined, + entries: VariableEntry[], + stackLocals: StackLocal[], +): Ir.Instruction.Debug { + const { stored: _, stackLocals: __, ...rest } = debug ?? {}; + if (entries.length === 0) return rest; + + const context = (rest.context ?? {}) as Record; + const locals = new Set(entries.map((e) => e.identifier)); + const storage = ( + Array.isArray(context.variables) + ? (context.variables as VariableEntry[]) + : [] + ).filter((v) => !locals.has(v.identifier)); + + return { + ...rest, + context: { + ...context, + variables: [...storage, ...entries], + } as Format.Program.Context, + ...(stackLocals.length > 0 ? { stackLocals } : {}), + }; +} + +/** + * Give each listed local whose temp is on the stack a stack pointer, + * for an instruction just emitted with the stack as it is after it + * (`stack[0]` is the top). Called as code generation emits each + * instruction. + */ +export function withStackLocals( + instruction: I, + stack: ReadonlyArray<{ irValue?: string }>, +): I { + const debug = instruction.debug; + if (!debug?.stackLocals) return instruction; + + const context = debug.context as Record; + const variables = (context.variables as VariableEntry[]).map((entry) => { + const local = debug.stackLocals!.find( + (l) => l.identifier === entry.identifier, + ); + if (!local || entry.pointer) return entry; + const slot = stack.findIndex((item) => item.irValue === local.temp); + if (slot < 0) return entry; + return { + ...entry, + pointer: withData( + stackPointer(local.identifier, slot, local.size), + local.data, + ), + }; + }); + return { + ...instruction, + debug: { + ...debug, + context: { ...context, variables } as Format.Program.Context, + }, + }; +} + +/** + * Drop a function's locals from a debug context, for the instructions + * that tear its frame down. Once the caller's frame pointer is + * restored, a frame-relative pointer names the caller's words, and the + * function's locals are out of scope. A local is an entry with a + * frame-relative pointer or none (type-only, or on the stack); other + * entries (storage) stay. + */ +export function withoutFrameLocals( + debug: Ir.Instruction.Debug, +): Ir.Instruction.Debug { + const context = debug.context as Record | undefined; + if (!context || !Array.isArray(context.variables)) return debug; + + const isLocal = (entry: VariableEntry) => + !entry.pointer || + ("group" in entry.pointer && + (entry.pointer.group[0] as { name?: string }).name === FRAME); + const variables = (context.variables as VariableEntry[]).filter( + (entry) => !isLocal(entry), + ); + + const rest = Object.fromEntries( + Object.entries(context).filter(([key]) => key !== "variables"), + ); + const { stackLocals: _, stored: __, ...others } = debug; + return { + ...others, + context: (variables.length > 0 + ? { ...rest, variables } + : rest) as Format.Program.Context, + }; +} + +/** The source offsets of an instruction/terminator: of its `code` + * context, or of each `code` in a `gather` (all of which apply). */ +function codeOffsets(context: unknown): number[] { + if (!context || typeof context !== "object") return []; + const { code, gather } = context as { + code?: { range?: { offset?: unknown } }; + gather?: unknown[]; + }; + const offset = code?.range?.offset; + return [ + ...(offset == null ? [] : [Number(offset)]), + ...(Array.isArray(gather) ? gather.flatMap(codeOffsets) : []), + ]; +} + +/** + * Among versions with a def site, the current one at (block, index): + * the deepest whose def dominates it. Undefined if none does yet. + */ +function currentVersion( + versions: Array<{ version: V; def: DefSite }>, + block: string, + index: number, + idom: Record, +): V | undefined { + let best: { version: V; def: DefSite } | undefined; + for (const candidate of versions) { + const { def } = candidate; + if (!Ir.Utils.dominates(def, block, index, idom)) continue; + const later = + !best || + (def.block === best.def.block + ? def.index > best.def.index + : Ir.Utils.dominatesBlock(best.def.block, def.block, idom)); + if (later) best = candidate; + } + return best?.version; +} + +/** What the snapshot needs to know about one function. */ +interface FunctionInfo { + declarations: Declaration[]; + /** The temp holding each version's value */ + tempOf: Map; + defs: Map; + idom: Record; + origins: Ir.Function["origins"]; + allocations: Record; + frameSize: number | undefined; + sourceId: string; +} + +/** A position, after optimization and (if known) before it */ +interface Position { + block: string; + index: number; + origin?: Ir.Function.Origin; +} + +/** + * The version of a declaration whose value is current at `at`, if its + * value is located there; undefined if not. + * + * With the position before optimization known, the current version is + * decided there, among all versions: if it is one the optimizer + * removed, there is no value to point to. Without it, a declaration + * with any removed or aliasing version gets no pointer. + */ +function locatedVersion( + info: FunctionInfo, + declaration: Declaration, + at: Position, +): { ssa: Ir.Function.SsaVariable; temp: string } | undefined { + const defined = (ssa: Ir.Function.SsaVariable) => + info.defs.get(info.tempOf.get(ssa)!); + const versions = declaration.versions.filter((v) => !v.placeholder); + + let current: Ir.Function.SsaVariable | undefined; + const { origin } = at; + const idom = origin && info.origins?.get(origin.function); + if ( + origin && + idom && + versions.every((v) => v.origin?.function === origin.function) + ) { + current = currentVersion( + versions.map((version) => ({ version, def: version.origin! })), + origin.block, + origin.index, + idom, + ); + } else { + // Without the position before optimization, a removed version, or + // one defined apart from its temp, cannot be placed + if (versions.some((v) => !defined(v) || v.defined)) return undefined; + current = currentVersion( + versions.map((version) => ({ version, def: defined(version)! })), + at.block, + at.index, + info.idom, + ); + } + + const def = current && defined(current); + if (!def || !Ir.Utils.dominates(def, at.block, at.index, info.idom)) { + return undefined; + } + return { ssa: current!, temp: info.tempOf.get(current!)! }; +} + +/** + * The snapshot at `at` with source `offsets`, for code inlined through + * `site` (undefined: the function's own code). LIST axis: for each + * name, the innermost declaration in scope (name + type). POINTER + * axis: its current version, if located, gets a memory pointer if + * memory-homed, else is left for the stack. + */ +function snapshotAt( + info: FunctionInfo, + at: Position, + offsets: number[], + site: string | undefined, + overwritten: Set, +): { entries: VariableEntry[]; stackLocals: StackLocal[] } { + const inScope = (d: Declaration) => + offsets.length === 0 || + offsets.some((offset) => offset >= d.start && offset < d.end); + + const listed = new Map(); + for (const declaration of info.declarations) { + if (declaration.site !== site || !inScope(declaration)) continue; + const other = listed.get(declaration.name); + if (!other || declaration.start > other.start) { + listed.set(declaration.name, declaration); + } + } + + const entries: VariableEntry[] = []; + const stackLocals: StackLocal[] = []; + for (const declaration of listed.values()) { + const found = locatedVersion(info, declaration, at); + const located = found && !overwritten.has(found.temp) ? found : undefined; + const type = (located?.ssa ?? declaration.versions[0]).type; + const entry = buildEntry(declaration, type, info.sourceId); + entries.push(entry); + if (!located) continue; + + // A scalar's word holds its value; a reference's word holds the + // address of its data. + const { name } = declaration; + let size = 32; + let data: Format.Pointer[] | undefined; + if (Ir.Type.isScalar(type)) { + size = type.size; + } else { + if (type.kind !== "ref" || type.location !== "memory") continue; + data = dataPointers({ $read: name }, type.origin, name); + if (!data) continue; + } + const allocation = info.allocations[located.temp]; + if (allocation) { + entry.pointer = withData( + memoryPointer(name, size, allocation, info.frameSize), + data, + ); + } else { + stackLocals.push({ + identifier: name, + temp: located.temp, + size, + ...(data ? { data } : {}), + }); + } + } + return { entries, stackLocals }; +} + +/** + * Stamp every instruction and terminator of a function, and each + * block's entry, with its variable snapshot. + */ +function enrichFunction( + func: Ir.Function, + module: Ir.Module, + memory: Memory.Function.Info, + sourceId: string, +): void { + if (!func.ssaVariables || func.ssaVariables.size === 0) return; + + const tempOf = new Map(); + for (const [key, ssa] of func.ssaVariables) { + tempOf.set(ssa, ssa.temp ?? key); + } + + const info: FunctionInfo = { + declarations: declarations(func), + tempOf, + defs: Ir.Utils.defSites(func), + idom: new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree, + origins: func.origins, + allocations: memory.allocations, + frameSize: memory.frameSize, + sourceId, + }; + const homed = new Set( + [...tempOf.values()].filter((t) => info.allocations[t] !== undefined), + ); + + // Carry the last known source offsets forward (function-wide) so + // synthesized ops without a `code` context inherit a nearby scope. + let lastOffsets: number[] = []; + const stamp = ( + debug: Ir.Instruction.Debug | undefined, + block: string, + index: number, + // Shift the position back (-1: just before this instruction) + shift: number = 0, + // Temps whose homes the instruction's code overwrites + overwritten: Set = new Set(), + ): Ir.Instruction.Debug => { + const own = codeOffsets(debug?.context); + const offsets = own.length > 0 ? own : lastOffsets; + lastOffsets = offsets; + const origin = debug?.origin && { + ...debug.origin, + index: debug.origin.index + shift, + }; + const { entries, stackLocals } = snapshotAt( + info, + { block, index: index + shift, origin }, + offsets, + debug?.inlineSites?.at(-1), + overwritten, + ); + return withVariables(debug, entries, stackLocals); + }; + + // A call's memory-homed result is on the stack at its continuation's + // entry, and in its home only once spilled. + const spilled = new Map(); + for (const block of func.blocks.values()) { + const term = block.terminator; + if (term.kind === "call" && term.dest && homed.has(term.dest)) { + spilled.set(term.continuation, term.dest); + } + } + + for (const [blockId, block] of func.blocks) { + // The block's entry: just before its first instruction (or its + // terminator), with that instruction's scope and inline site. + const first = block.instructions[0] ?? block.terminator; + const result = spilled.get(blockId); + const entry = stamp( + first.operationDebug, + blockId, + 0, + -1, + new Set(result ? [result] : []), + ); + const { variables } = (entry.context ?? {}) as { variables?: unknown }; + block.entryDebug = variables + ? { + context: { variables } as Format.Program.Context, + ...(entry.stackLocals ? { stackLocals: entry.stackLocals } : {}), + } + : {}; + + block.instructions.forEach((inst, i) => { + const debug = inst.operationDebug; + // An instruction that defines a memory-homed local computes its + // value before the MSTORE that homes it. Contexts are + // postconditions, so the instruction's ops carry the snapshot + // from before the def, and only the store carries the snapshot + // that locates the new value. + if (!("dest" in inst) || !homed.has(inst.dest as string)) { + inst.operationDebug = stamp(debug, blockId, i); + return; + } + const stored = stamp(debug, blockId, i); + inst.operationDebug = { ...stamp(debug, blockId, i, -1), stored }; + }); + // The terminator's position is after all instructions in the block. + const term = block.terminator; + if (!term) continue; + const debug = term.operationDebug; + // A jump into a block with phis first copies the phis' values into + // their homes; a local whose current version is one of those phis + // would read the next value early, so it has no pointer here. + const phis = term.kind === "jump" ? func.blocks.get(term.target)?.phis : []; + term.operationDebug = stamp( + debug, + blockId, + block.instructions.length, + 0, + new Set((phis ?? []).map((phi) => phi.dest)), + ); + // A call's memory-homed result is spilled when its continuation + // begins; that store carries the continuation's snapshot. + if (term.kind === "call" && term.dest && homed.has(term.dest)) { + const { origin: __, ...unplaced } = debug ?? {}; + term.operationDebug.stored = stamp(unplaced, term.continuation, -1); + } + } +} + +/** + * Enrich a module's instructions with per-instruction local-variable + * snapshots. Mutates in place; safe before generation. + */ +export function enrich( + module: Ir.Module, + memory: Memory.Module.Info, +): Ir.Module { + const sourceId = module.sourceId; + + const targets: { func: Ir.Function; mem?: Memory.Function.Info }[] = [ + { func: module.main, mem: memory.main }, + ...(module.create ? [{ func: module.create, mem: memory.create }] : []), + ...[...module.functions].map(([name, func]) => ({ + func, + mem: memory.functions[name], + })), + ]; + + for (const { func, mem } of targets) { + if (!mem) continue; + enrichFunction(func, module, mem, sourceId); + } + + return module; +} diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 370e33eb38..a6782f1bfe 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -21,6 +21,7 @@ import { generateCallTerminator, } from "./control-flow/index.js"; import { annotateTop } from "./values/identify.js"; +import { withStackLocals } from "../debug/local-variables.js"; /** * Generate code for a basic block @@ -114,15 +115,18 @@ export function generate( }, }, }; + const entry = block.entryDebug; const continuationDebug = { + ...entry, context: { + ...entry?.context, ...returnCtx, ...(callSiteCode ? { code: callSiteCode } : {}), } as Format.Program.Context, }; result = result.then(JUMPDEST({ debug: continuationDebug })); } else { - result = result.then(JUMPDEST()); + result = result.then(JUMPDEST({ debug: block.entryDebug })); } // Annotate TOS with dest variable if this is a continuation with return value. @@ -156,11 +160,16 @@ export function generate( s.memory.frameSize !== undefined, spillDebug, ), - { - mnemonic: "MSTORE" as const, - opcode: 0x52, - debug: spillDebug, - }, + // The stack after the store is the stack before + // the DUP1. + withStackLocals( + { + mnemonic: "MSTORE" as const, + opcode: 0x52, + debug: spillDebug?.stored ?? spillDebug, + }, + s.stack, + ), ], }; }); @@ -214,7 +223,13 @@ export function generate( if (func && block.terminator.kind === "jump") { const target = func.blocks.get(block.terminator.target); if (target && target.phis.length > 0) { - result = result.then(generatePhis(target.phis, block.id)); + result = result.then( + generatePhis( + target.phis, + block.id, + block.terminator.operationDebug, + ), + ); } } else if (func && block.terminator.kind === "branch") { for (const targetId of [ @@ -257,12 +272,13 @@ export function generate( function generatePhis( phis: Ir.Block.Phi[], predecessor: string, + debug: Ir.Block.Debug, ): Transition { // The stack grows by one per load and shrinks by one per store, // which the types cannot follow across a list const steps = [ - ...phis.map((phi) => loadPhiSource(phi, predecessor)), - ...phis.map((phi) => storePhiDest(phi)).reverse(), + ...phis.map((phi) => loadPhiSource(phi, predecessor, debug)), + ...phis.map((phi) => storePhiDest(phi, debug)).reverse(), ] as unknown as Transition[]; return (state) => steps.reduce((current, step) => step(current), state); } @@ -270,6 +286,7 @@ function generatePhis( function loadPhiSource( phi: Ir.Block.Phi, predecessor: string, + debug: Ir.Block.Debug, ): Transition { const source = phi.sources.get(predecessor); if (!source) { @@ -279,11 +296,12 @@ function loadPhiSource( ); } - return loadValue(source); + return loadValue(source, { debug }); } function storePhiDest( phi: Ir.Block.Phi, + debug: Ir.Block.Debug, ): Transition { const { PUSHn, ADD, MLOAD, MSTORE } = operations; @@ -298,19 +316,21 @@ function storePhiDest( } if (state.memory.frameSize !== undefined) { return builder - .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER)), { as: "offset" }) - .then(MLOAD(), { as: "b" }) - .then(PUSHn(BigInt(allocation.offset)), { + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), { debug }), { + as: "offset", + }) + .then(MLOAD({ debug }), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset), { debug }), { as: "a", }) - .then(ADD(), { as: "offset" }) - .then(MSTORE()); + .then(ADD({ debug }), { as: "offset" }) + .then(MSTORE({ debug })); } return builder - .then(PUSHn(BigInt(allocation.offset)), { + .then(PUSHn(BigInt(allocation.offset), { debug }), { as: "offset", }) - .then(MSTORE()); + .then(MSTORE({ debug })); }) .done(); } diff --git a/packages/bugc/src/evmgen/generation/bracket-activation.ts b/packages/bugc/src/evmgen/generation/bracket-activation.ts index 653c2ffc87..88a2886a78 100644 --- a/packages/bugc/src/evmgen/generation/bracket-activation.ts +++ b/packages/bugc/src/evmgen/generation/bracket-activation.ts @@ -97,6 +97,18 @@ export function stripActivation(ctx: Ctx | undefined): Ctx | undefined { const kids = ctx.gather .map(stripActivation) .filter((c): c is Ctx => c !== undefined); + // Keys beside the gather (e.g. local `variables`) still apply. + const { + gather: _, + invoke: __, + return: ___, + ...siblings + } = ctx as Record; + if (Object.keys(siblings).length > 0) { + return ( + kids.length > 0 ? { ...siblings, gather: kids } : siblings + ) as Ctx; + } if (kids.length === 0) return undefined; if (kids.length === 1) return kids[0]; return { gather: kids } as Ctx; @@ -118,7 +130,7 @@ function attachActivation( const marker = { [key]: value } as Ctx; if (!ctx || typeof ctx !== "object") return marker; if (isPick(ctx)) return { pick: [...ctx.pick, marker] } as Ctx; - if (isGather(ctx)) return { gather: [...ctx.gather, marker] } as Ctx; + if (isGather(ctx)) return { ...ctx, gather: [...ctx.gather, marker] } as Ctx; return { ...(ctx as Record), [key]: value } as Ctx; } diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 54b30422a4..2ebef36ad3 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -9,6 +9,11 @@ import { Memory } from "#evmgen/analysis"; import { type Transition, operations, pipe } from "#evmgen/operations"; import { valueId, loadValue } from "../values/index.js"; +import { + withStackLocals, + withoutFrameLocals, +} from "../../debug/local-variables.js"; +import { stripActivation } from "../bracket-activation.js"; /** * Extract the `code` source-range context from an instruction or @@ -24,6 +29,28 @@ function codeContext( return undefined; } +/** + * Extract the in-scope `variables` list from an instruction/terminator + * debug, if present, so a call's caller JUMP still lists the locals + * that are in scope at the call (they must not vanish at the call). + */ +function variablesContext( + debug: { context?: Format.Program.Context } | undefined, +): Format.Program.Context.Variables["variables"] | undefined { + const ctx = debug?.context as Record | undefined; + if (ctx && Array.isArray(ctx.variables) && ctx.variables.length > 0) { + return ctx.variables as Format.Program.Context.Variables["variables"]; + } + return undefined; +} + +/** A terminator's debug without invoke/return discriminators */ +function withoutActivation(debug: Ir.Block.Debug): Ir.Block.Debug { + const context = stripActivation(debug.context); + const { context: _, ...rest } = debug; + return context ? { ...rest, context } : rest; +} + /** * Generate code for a block terminator */ @@ -88,9 +115,11 @@ export function generateTerminator( // invoke discriminators. Depth stays constant: one pops, // one pushes, on the same instruction. The function's // terminal RETURN pops the final iteration's frame normally. + // Activation markers already ride the block's instructions. + const debug = withoutActivation(term.operationDebug); const jumpDebug = term.tailCall ? buildTailCallJumpOptions(term.tailCall).debug - : undefined; + : debug; // Imperative, like generateCallTerminator/generateReturnEpilogue: // drop any leftover block-local scratch so the target block is @@ -98,13 +127,14 @@ export function generateTerminator( return ((state: State): State => { let s = state as State; while (s.stack.length > 0) { + const stack = s.stack.slice(1); s = { ...s, instructions: [ ...s.instructions, - { mnemonic: "POP", opcode: 0x50 }, + withStackLocals({ mnemonic: "POP", opcode: 0x50, debug }, stack), ], - stack: s.stack.slice(1), + stack, brands: s.brands.slice(1), }; } @@ -114,12 +144,14 @@ export function generateTerminator( ...s, instructions: [ ...s.instructions, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { - mnemonic: "JUMP", - opcode: 0x56, - ...(jumpDebug ? { debug: jumpDebug } : {}), - }, + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}], + ), + withStackLocals( + { mnemonic: "JUMP", opcode: 0x56, debug: jumpDebug }, + [], + ), ], patches: [...s.patches, { index: patchIndex, target: term.target }], stack: [], @@ -133,31 +165,49 @@ export function generateTerminator( // beneath it (SWAP1/POP) so both successors are entered with the // canonical empty stack — the JUMPI and the fall-through JUMP // consume the condition and the pushed counters. + const debug = withoutActivation(term.operationDebug); return ((state: State): State => { - let s: State = loadValue(term.condition)(state as State); + let s: State = loadValue(term.condition, { debug })( + state as State, + ); while (s.stack.length > 1) { + const [top, below, ...rest] = s.stack; s = { ...s, instructions: [ ...s.instructions, - { mnemonic: "SWAP1", opcode: 0x90 }, - { mnemonic: "POP", opcode: 0x50 }, + withStackLocals({ mnemonic: "SWAP1", opcode: 0x90, debug }, [ + below, + top, + ...rest, + ]), + withStackLocals({ mnemonic: "POP", opcode: 0x50, debug }, [ + top, + ...rest, + ]), ], - stack: [s.stack[0], ...s.stack.slice(2)], + stack: [top, ...rest], brands: [s.brands[0], ...s.brands.slice(2)], }; } const trueIndex = s.instructions.length; const falseIndex = trueIndex + 2; + const condition = s.stack; return { ...s, instructions: [ ...s.instructions, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { mnemonic: "JUMPI", opcode: 0x57 }, - { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] }, - { mnemonic: "JUMP", opcode: 0x56 }, + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}, ...condition], + ), + withStackLocals({ mnemonic: "JUMPI", opcode: 0x57, debug }, []), + withStackLocals( + { mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], debug }, + [{}], + ), + withStackLocals({ mnemonic: "JUMP", opcode: 0x56, debug }, []), ], patches: [ ...s.patches, @@ -288,10 +338,12 @@ export function generateCallTerminator( // flat alongside the invoke, so the caller JUMP maps back to the // call expression. invoke and code are disjoint keys. const callSiteCode = codeContext(debug); + const inScopeVars = variablesContext(debug); const invokeContext = { context: { ...invoke, ...(callSiteCode ? { code: callSiteCode } : {}), + ...(inScopeVars ? { variables: inScopeVars } : {}), } as Format.Program.Context, }; @@ -402,6 +454,9 @@ function generateReturnEpilogue( const FP = Memory.regions.FRAME_POINTER; const FMP = Memory.regions.FREE_MEMORY_POINTER; const pcOff = Memory.frameHeader.SAVED_RETURN_PC; + // Restoring the caller's frame pointer ends this frame: from that + // MSTORE on, the function's locals are no longer listed. + const teardown = withoutFrameLocals(debug); s = { ...s, @@ -429,18 +484,18 @@ function generateReturnEpilogue( // mem[FRAME_POINTER] = old_fp { mnemonic: "DUP1", opcode: 0x80, debug }, ...pushImm(FP, debug), - { mnemonic: "MSTORE", opcode: 0x52, debug }, + { mnemonic: "MSTORE", opcode: 0x52, debug: teardown }, // Stack: [old_fp, fp, return_pc, ...] // mem[FREE_MEMORY_POINTER] = fp (deallocate) - { mnemonic: "POP", opcode: 0x50, debug }, + { mnemonic: "POP", opcode: 0x50, debug: teardown }, // Stack: [fp, return_pc, ...] - ...pushImm(FMP, debug), - { mnemonic: "MSTORE", opcode: 0x52, debug }, + ...pushImm(FMP, teardown), + { mnemonic: "MSTORE", opcode: 0x52, debug: teardown }, // Stack: [return_pc, ...] // JUMP - { mnemonic: "JUMP", opcode: 0x56, debug }, + { mnemonic: "JUMP", opcode: 0x56, debug: teardown }, ], }; diff --git a/packages/bugc/src/evmgen/generation/values/identify.ts b/packages/bugc/src/evmgen/generation/values/identify.ts index 2b2d641daa..0de0875177 100644 --- a/packages/bugc/src/evmgen/generation/values/identify.ts +++ b/packages/bugc/src/evmgen/generation/values/identify.ts @@ -1,6 +1,7 @@ import type * as Ir from "#ir"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; +import { withStackLocals } from "../../debug/local-variables.js"; /** * Get the ID for a value @@ -33,8 +34,14 @@ export const annotateTop = irValue, }; + // The value on top came from the last instruction, so a local it + // holds is on the stack from that instruction on. + const last = state.instructions.at(-1); return { ...state, stack: newStack, + instructions: last + ? [...state.instructions.slice(0, -1), withStackLocals(last, newStack)] + : state.instructions, }; }; diff --git a/packages/bugc/src/evmgen/generation/values/store.ts b/packages/bugc/src/evmgen/generation/values/store.ts index 66751733f1..227220a9bb 100644 --- a/packages/bugc/src/evmgen/generation/values/store.ts +++ b/packages/bugc/src/evmgen/generation/values/store.ts @@ -1,5 +1,6 @@ import * as Evm from "#evm"; import type { Stack } from "#evm"; +import type * as Ir from "#ir"; import { type Transition, operations, pipe } from "#evmgen/operations"; import { Memory } from "#evmgen/analysis"; @@ -19,6 +20,11 @@ export const storeValueIfNeeded = ( ): Transition => { const { PUSHn, ADD, DUP2, SWAP1, MLOAD, MSTORE } = operations; + // The store itself carries the context that holds once the value is + // in memory (contexts are postconditions); the ops before it do not. + const { stored } = (options?.debug ?? {}) as Ir.Instruction.Debug; + const storeOptions = stored ? { debug: stored } : options; + return ( pipe() // First annotate the top value with the destination ID @@ -46,7 +52,7 @@ export const storeValueIfNeeded = ( .then(ADD(options), { as: "offset" }) .then(DUP2(options)) .then(SWAP1(options)) - .then(MSTORE(options)); + .then(MSTORE(storeOptions)); } return builder .then(PUSHn(BigInt(allocation.offset), options), { @@ -54,7 +60,7 @@ export const storeValueIfNeeded = ( }) .then(DUP2(options)) .then(SWAP1(options)) - .then(MSTORE(options)); + .then(MSTORE(storeOptions)); }) .done() ); diff --git a/packages/bugc/src/evmgen/pass.ts b/packages/bugc/src/evmgen/pass.ts index d741f5fca0..fba894757d 100644 --- a/packages/bugc/src/evmgen/pass.ts +++ b/packages/bugc/src/evmgen/pass.ts @@ -9,6 +9,7 @@ import { Error as EvmgenError, ErrorCode } from "#evmgen/errors"; import { buildProgram } from "#evmgen/program-builder"; import { Layout, Liveness, Memory } from "#evmgen/analysis"; +import { enrich as enrichLocalVariables } from "./debug/local-variables.js"; import { splitPhiBranchEdges } from "./split-edges.js"; @@ -61,6 +62,12 @@ const pass: Pass<{ ); } + // Attach local-variable debug info to the IR before generation, + // so it propagates onto the emitted instructions (stack pointers + // are resolved as they are emitted). Debug info only: it does not + // change the code. + enrichLocalVariables(ir, memoryResult.value); + // Analyze block layout const blockResult = Layout.Module.perform(ir); if (!blockResult.success) { diff --git a/packages/bugc/src/evmgen/split-edges.ts b/packages/bugc/src/evmgen/split-edges.ts index 03ac4697d4..0675e95513 100644 --- a/packages/bugc/src/evmgen/split-edges.ts +++ b/packages/bugc/src/evmgen/split-edges.ts @@ -1,4 +1,4 @@ -import type * as Ir from "#ir"; +import * as Ir from "#ir"; /** * Codegen stores a phi's source value just before the predecessor @@ -73,5 +73,7 @@ function splitFunction(func: Ir.Function): Ir.Function { }); } - return { ...func, blocks }; + const result = { ...func, blocks }; + Ir.Utils.copyDebugInfo(func, result); + return result; } diff --git a/packages/bugc/src/evmgen/state.ts b/packages/bugc/src/evmgen/state.ts index f4473ea8b6..938f24d4a8 100644 --- a/packages/bugc/src/evmgen/state.ts +++ b/packages/bugc/src/evmgen/state.ts @@ -5,6 +5,7 @@ import { type _ } from "#evm"; import * as Analysis from "#evmgen/analysis"; import type { Error } from "#evmgen/errors"; +import { withStackLocals } from "./debug/local-variables.js"; // Debug context type for EVM instructions export type DebugContext = { @@ -91,9 +92,14 @@ const unsafe: Evm.Unsafe.StateControls = { nextId: state.nextId + 1, }, }), + // `state` is the state after the instruction, so its stack gives + // the stack pointers of the locals the instruction lists. emit: (state, instruction) => ({ ...state, - instructions: [...state.instructions, instruction], + instructions: [ + ...state.instructions, + withStackLocals(instruction, state.stack), + ], }), }; diff --git a/packages/bugc/src/ir/spec/block.ts b/packages/bugc/src/ir/spec/block.ts index ce64cd62b7..c4a40bd0c8 100644 --- a/packages/bugc/src/ir/spec/block.ts +++ b/packages/bugc/src/ir/spec/block.ts @@ -1,4 +1,3 @@ -import type * as Format from "@ethdebug/format"; import type * as Ast from "#ast"; import { Value } from "./value.js"; @@ -21,15 +20,15 @@ export interface Block { predecessors: Set; /** Debug information (e.g., for if/while blocks) */ debug: Block.Debug; + /** Debug for the block's entry (its JUMPDEST), set by code generation */ + entryDebug?: Block.Debug; } export namespace Block { /** * Debug information for blocks, terminators, and phi nodes */ - export interface Debug { - context?: Format.Program.Context; - } + export type Debug = Instruction.Debug; /** * Metadata for a jump that originated as a tail call. diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index 93d72d8c8e..75cd40a112 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -21,9 +21,26 @@ export interface Function { loc?: Ast.SourceLocation; /** Source ID for debug info (inherited from module) */ sourceId?: string; + /** + * Dominator trees from before optimization, by function name (this + * function's, and those of functions inlined into it). Instruction + * and version `origin`s refer to these. + */ + origins?: Map>; } export namespace Function { + /** + * A position in a function as it was before optimization: a block + * and an instruction index (`-1`: block entry, i.e. a parameter or + * phi; the instruction count: the terminator). + */ + export interface Origin { + function: string; + block: string; + index: number; + } + /** * Function parameter in SSA form */ @@ -52,5 +69,47 @@ export namespace Function { version: number; /** Source location of declaration */ loc?: Ast.SourceLocation; + /** + * Source offset at which the variable's enclosing lexical scope + * ends. Together with `loc`, gives the lexical extent over which + * the variable is in scope: `[loc.offset, scopeEnd)`. Used to + * list a variable (name + type) across its scope, independently + * of whether its value is currently located. + */ + scopeEnd?: number; + /** + * The source range (`offset:end`) of the scope that declares the + * variable; undefined for a function-level name (a parameter). + * With `name` (and `inlineSite`), it tells declarations apart. + */ + declaredIn?: string; + /** + * Where the version is defined, if not where its temp is: a + * version that aliases a temp computed earlier is defined at the + * assignment, between instructions (a fractional index). + */ + defined?: { block: string; index: number }; + /** + * Where the version was defined before optimization (see + * `Function.origins`), if it was defined. + */ + origin?: Function.Origin; + /** + * The temp that holds this variable's value, where it is not the + * temp this entry is keyed by (e.g. an inlined parameter, whose + * value is the call's argument). + */ + temp?: string; + /** + * A version whose temp the function never defines (a declaration's + * own version, replaced by its initializer's temp). It carries the + * declaration's location and scope only. + */ + placeholder?: boolean; + /** + * For a variable of an inlined callee, the inline site it belongs + * to (see `Instruction.Debug.inlineSites`). + */ + inlineSite?: string; } } diff --git a/packages/bugc/src/ir/spec/instruction.ts b/packages/bugc/src/ir/spec/instruction.ts index 2720c10c18..1d072c020c 100644 --- a/packages/bugc/src/ir/spec/instruction.ts +++ b/packages/bugc/src/ir/spec/instruction.ts @@ -1,6 +1,7 @@ import * as Format from "@ethdebug/format"; import type { Type } from "./type.js"; +import type { Function } from "./function.js"; import { Value } from "./value.js"; export type Instruction = @@ -39,6 +40,30 @@ export namespace Instruction { export interface Debug { context?: Format.Program.Context; + /** + * Debug for the MSTORE that homes this instruction's result in + * memory, where it differs from this one: from that store on, a + * local whose value the result is can be listed with a pointer. + */ + stored?: Debug; + /** + * Inline sites this instruction was inlined through, outermost + * first. Only the innermost site's locals are listed here. + */ + inlineSites?: string[]; + /** Where the instruction was before optimization */ + origin?: Function.Origin; + /** + * Listed locals whose value may be on the stack: code generation + * gives each a stack pointer where the stack holds its temp. + */ + stackLocals?: Array<{ + identifier: string; + temp: string; + size: number; + /** For a reference, the pointers to the data it refers to */ + data?: Format.Pointer[]; + }>; } // Location types for unified read/write diff --git a/packages/bugc/src/ir/utils/debug-info.ts b/packages/bugc/src/ir/utils/debug-info.ts new file mode 100644 index 0000000000..237a8225b6 --- /dev/null +++ b/packages/bugc/src/ir/utils/debug-info.ts @@ -0,0 +1,25 @@ +import type * as Ir from "#ir"; + +/** + * Set a function's debug info (variable metadata, origins) where the + * optimizer's fixpoint check, which compares modules as JSON, does not + * see it: debug info must not change the generated code. + */ +export function setDebugInfo( + func: Ir.Function, + key: K, + value: Ir.Function[K], +): void { + Object.defineProperty(func, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }); +} + +/** Carry a function's debug info over to a copy of it */ +export function copyDebugInfo(from: Ir.Function, to: Ir.Function): void { + if (from.ssaVariables) setDebugInfo(to, "ssaVariables", from.ssaVariables); + if (from.origins) setDebugInfo(to, "origins", from.origins); +} diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index e34120f672..7a0e0ed0e0 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -9,6 +9,27 @@ import type * as Format from "@ethdebug/format"; */ export function combineDebugContexts( ...debugs: (Ir.Instruction.Debug | Ir.Block.Debug | undefined)[] +): Ir.Instruction.Debug { + // The first debug is the instruction that stays in place (others + // are folded into it), so the result keeps its inline sites and + // origin. + return withPlacement(combineContexts(debugs), debugs[0]); +} + +/** Keep an instruction's inline sites and origin on a new debug. */ +function withPlacement( + debug: Ir.Instruction.Debug, + from: Ir.Instruction.Debug | undefined, +): Ir.Instruction.Debug { + return { + ...debug, + ...(from?.inlineSites ? { inlineSites: from.inlineSites } : {}), + ...(from?.origin ? { origin: from.origin } : {}), + }; +} + +function combineContexts( + debugs: (Ir.Instruction.Debug | Ir.Block.Debug | undefined)[], ): Ir.Instruction.Debug { // Filter out undefined and empty debug objects const contexts = debugs @@ -396,9 +417,8 @@ export function addTransform( const transform = [...prior, ...ids]; - if (!existing) { - return { context: { transform } }; - } - - return { context: { ...existing, transform } }; + return withPlacement( + { context: { ...(existing ?? {}), transform } as Format.Program.Context }, + debug, + ); } diff --git a/packages/bugc/src/ir/utils/def-sites.ts b/packages/bugc/src/ir/utils/def-sites.ts new file mode 100644 index 0000000000..41910f597b --- /dev/null +++ b/packages/bugc/src/ir/utils/def-sites.ts @@ -0,0 +1,63 @@ +import type * as Ir from "#ir"; + +/** + * Where a temp is defined: a block plus an instruction index (`-1`: + * block entry, i.e. a parameter or a phi, before all instructions). + */ +export interface DefSite { + block: string; + index: number; +} + +/** + * The def site of every temp a function defines: parameters and phis + * at block entry, instruction results at their index, and call + * results at their continuation's entry (the result is on the stack + * when the continuation begins). + */ +export function defSites(func: Ir.Function): Map { + const defs = new Map(); + for (const p of func.parameters) { + defs.set(p.tempId, { block: func.entry, index: -1 }); + } + for (const [blockId, block] of func.blocks) { + for (const phi of block.phis ?? []) { + defs.set(phi.dest, { block: blockId, index: -1 }); + } + block.instructions.forEach((inst, i) => { + if ("dest" in inst && typeof inst.dest === "string") { + defs.set(inst.dest, { block: blockId, index: i }); + } + }); + const term = block.terminator; + if (term.kind === "call" && term.dest) { + defs.set(term.dest, { block: term.continuation, index: -1 }); + } + } + return defs; +} + +/** Whether block `a` dominates (or is) block `b`. */ +export function dominatesBlock( + a: string, + b: string, + idom: Record, +): boolean { + let current: string | null = b; + while (current !== null) { + if (current === a) return true; + current = idom[current] ?? null; + } + return false; +} + +/** Whether def site `d` dominates (or is) position (block, index). */ +export function dominates( + d: DefSite, + block: string, + index: number, + idom: Record, +): boolean { + if (d.block === block) return d.index <= index; + return dominatesBlock(d.block, block, idom); +} diff --git a/packages/bugc/src/ir/utils/index.ts b/packages/bugc/src/ir/utils/index.ts index 2f538a5122..da8e7954bf 100644 --- a/packages/bugc/src/ir/utils/index.ts +++ b/packages/bugc/src/ir/utils/index.ts @@ -1,2 +1,4 @@ export * from "./debug.js"; export * from "./cast.js"; +export * from "./def-sites.js"; +export * from "./debug-info.js"; diff --git a/packages/bugc/src/irgen/generate/function.ts b/packages/bugc/src/irgen/generate/function.ts index 59e1527687..01d7512d08 100644 --- a/packages/bugc/src/irgen/generate/function.ts +++ b/packages/bugc/src/irgen/generate/function.ts @@ -12,6 +12,7 @@ export function* buildFunction( parameters: { name: string; type: Ir.Type; + loc?: Ast.SourceLocation; }[], body: Ast.Block, ): Process { @@ -43,8 +44,19 @@ export function* buildFunction( const blocks = computePredecessors(blocksBeforeCompute); const params = yield* Process.Functions.currentParameters(); - // Collect SSA variable metadata - const ssaVariables = yield* Process.Functions.collectSsaMetadata(); + // Collect SSA variable metadata. A declaration can make a version + // whose temp is never defined; mark it, so debug info can tell it + // from a version the optimizer removed. + const defined = definedTemps(blocks, params); + const ssaVariables = new Map( + [...(yield* Process.Functions.collectSsaMetadata())].map( + ([key, ssa]) => + [ + key, + defined.has(ssa.temp ?? key) ? ssa : { ...ssa, placeholder: true }, + ] as const, + ), + ); const module_ = yield* Process.Modules.current(); @@ -61,6 +73,26 @@ export function* buildFunction( return function_; } +/** Temps a function defines: parameters, phis, results, call results */ +function definedTemps( + blocks: Map, + params: Ir.Function.Parameter[], +): Set { + const defined = new Set(params.map((p) => p.tempId)); + for (const block of blocks.values()) { + for (const phi of block.phis ?? []) defined.add(phi.dest); + for (const inst of block.instructions) { + if ("dest" in inst && typeof inst.dest === "string") { + defined.add(inst.dest); + } + } + if (block.terminator.kind === "call" && block.terminator.dest) { + defined.add(block.terminator.dest); + } + } + return defined; +} + /** * Compute predecessors for all blocks based on their terminators */ diff --git a/packages/bugc/src/irgen/generate/module.ts b/packages/bugc/src/irgen/generate/module.ts index dd3fd0ea39..caa8bb4471 100644 --- a/packages/bugc/src/irgen/generate/module.ts +++ b/packages/bugc/src/irgen/generate/module.ts @@ -66,6 +66,7 @@ export function* buildModule( const parameters = funcDecl.parameters.map((param, index) => ({ name: param.name, type: fromBugType(funcType.parameters[index]), + loc: param.loc ?? undefined, })); const func = yield* withErrorHandling( diff --git a/packages/bugc/src/irgen/generate/process.ts b/packages/bugc/src/irgen/generate/process.ts index fb9dee4c36..eda3fb507e 100644 --- a/packages/bugc/src/irgen/generate/process.ts +++ b/packages/bugc/src/irgen/generate/process.ts @@ -243,6 +243,37 @@ export namespace Process { * Variable and scope management */ export namespace Variables { + /** A scope's declarations, with `name` declared at `loc` */ + const declaredHere = ( + scope: State.Scope, + name: string, + loc: Ast.SourceLocation | undefined, + ): State.Scope["declared"] => + loc + ? new Map([ + ...(scope.declared ?? []), + [ + name, + { + offset: Number(loc.offset), + range: scope.declaringIn ?? scope.range, + }, + ], + ]) + : scope.declared; + + /** + * Make the current scope's next declarations in scope over `range` + * (a `for` loop's, for its init), or over the scope's own range + * again (undefined). + */ + export function* declaringIn( + range: Ast.SourceLocation | undefined, + ): Process { + const scope = yield* lift(State.Scopes.current)(); + yield* lift(State.Scopes.setCurrent)({ ...scope, declaringIn: range }); + } + /** * Declare a new SSA variable in the current scope */ @@ -267,6 +298,7 @@ export namespace Process { ...scope, ssaVars: new Map([...scope.ssaVars, [name, ssaVar]]), usedNames: new Map([...scope.usedNames, [name, version + 1]]), + declared: declaredHere(scope, name, loc), }; // Update scopes @@ -277,7 +309,7 @@ export namespace Process { (s) => s.stack.length - 1, ); const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(tempId, name, scopeId, type, version, loc); + yield* addSsaMetadata(tempId, name, scopeId, type, version, { loc }); return ssaVar; } @@ -290,6 +322,7 @@ export namespace Process { type: Ir.Type, tempId: string, loc?: Ast.SourceLocation, + options: { placeholder?: boolean } = {}, ): Process { const scope = yield* lift(State.Scopes.current)(); @@ -306,6 +339,7 @@ export namespace Process { ...scope, ssaVars: new Map([...scope.ssaVars, [name, ssaVar]]), usedNames: new Map([...scope.usedNames, [name, version + 1]]), + declared: declaredHere(scope, name, loc), }; // Update scopes @@ -316,7 +350,11 @@ export namespace Process { (s) => s.stack.length - 1, ); const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(tempId, name, scopeId, type, version, loc); + yield* addSsaMetadata(tempId, name, scopeId, type, version, { + loc, + alias: true, + placeholder: options.placeholder, + }); return ssaVar; } @@ -388,18 +426,68 @@ export namespace Process { scopeId: string, type: Ir.Type, version: number, - loc?: Ast.SourceLocation, + options: { + loc?: Ast.SourceLocation; + /** The temp holds a value computed earlier (perhaps another + * variable's): the version is defined here, not at the temp */ + alias?: boolean; + /** The version only marks the declaration; it holds no value */ + placeholder?: boolean; + } = {}, ): Process { + const { loc, alias, placeholder } = options; const state: State = yield { type: "peek" }; const currentMetadata = state.function.ssaMetadata || new Map(); + // The variable's declaring scope: the innermost scope that holds + // the name (every version of a declaration, phis included, names + // the scope that declared it). Its lexical scope ends where that + // scope's source range ends; a function-level name (a parameter) + // has no range. + const stack = state.scopes.stack; + let declaring = stack.length - 1; + while (declaring >= 0 && !stack[declaring].ssaVars.has(name)) { + declaring--; + } + const declaration = stack[declaring]?.declared?.get(name); + const scopeRange = declaration + ? declaration.range + : stack[declaring]?.range; + const scopeEnd = scopeRange + ? Number(scopeRange.offset) + Number(scopeRange.length) + : undefined; + // Two declarations of a name in one scope differ by where each + // is declared + const declaredIn = scopeRange + ? `${Number(scopeRange.offset)}:${scopeEnd}:${declaration?.offset ?? ""}` + : undefined; + + // A version that aliases a temp is defined just after the + // instructions emitted so far in the current block: from the + // next instruction on, never on an instruction of an earlier + // statement (contexts are postconditions). Each version has its + // own entry, so an aliased temp's key gets a suffix. + const defined = alias + ? { + block: state.block.id, + index: state.block.instructions.length - 0.5, + } + : undefined; + let key = tempId; + for (let n = 1; currentMetadata.has(key); n++) key = `${tempId}~${n}`; + const newMetadata = new Map(currentMetadata); - newMetadata.set(tempId, { + newMetadata.set(key, { name, scopeId, type, version, loc, + scopeEnd, + ...(declaredIn ? { declaredIn } : {}), + ...(key !== tempId ? { temp: tempId } : {}), + ...(defined ? { defined } : {}), + ...(placeholder ? { placeholder } : {}), }); // Update function with new metadata @@ -543,7 +631,10 @@ export namespace Process { }; /** - * Update an SSA variable to point to an existing temp without creating a new one + * Update an SSA variable to point to an existing temp without + * creating a new one. The new version aliases the temp: debug info + * records where the version is defined, here, apart from the temp's + * own definition. */ export function* updateSsaToExistingTemp( name: string, @@ -585,7 +676,9 @@ export namespace Process { // Track SSA metadata for the existing temp const scopeId = `scope_${scopeIndex === -1 ? 0 : scopeIndex}_${name}`; - yield* addSsaMetadata(existingTempId, name, scopeId, type, version); + yield* addSsaMetadata(existingTempId, name, scopeId, type, version, { + alias: true, + }); return; } @@ -617,7 +710,9 @@ export namespace Process { // Track SSA metadata for the existing temp const scopeId = `scope_${scopeIndex}_${name}`; - yield* addSsaMetadata(existingTempId, name, scopeId, type, newVersion); + yield* addSsaMetadata(existingTempId, name, scopeId, type, newVersion, { + alias: true, + }); } /** @@ -880,7 +975,7 @@ export namespace Process { */ export function* initialize( name: string, - parameters: { name: string; type: Ir.Type }[], + parameters: { name: string; type: Ir.Type; loc?: Ast.SourceLocation }[], ): Process { // Convert parameters to SSA form const ssaParams: Ir.Function.Parameter[] = []; @@ -910,6 +1005,7 @@ export namespace Process { scopeId: "param", type: param.type, version: 0, + ...(param.loc ? { loc: param.loc } : {}), }); } diff --git a/packages/bugc/src/irgen/generate/state.ts b/packages/bugc/src/irgen/generate/state.ts index efe70be2eb..4278e8cc34 100644 --- a/packages/bugc/src/irgen/generate/state.ts +++ b/packages/bugc/src/irgen/generate/state.ts @@ -169,6 +169,15 @@ export namespace State { export interface Scope { readonly ssaVars: Map; // SSA variable tracking readonly usedNames: Map; // For handling shadowing + readonly range?: Ast.SourceLocation; // Lexical extent of this scope + /** Where each name is declared, and the range it is in scope over */ + readonly declared?: Map< + string, + { offset: number; range?: Ast.SourceLocation } + >; + /** The range declarations made now are in scope over, if not this + * scope's (a `for` loop's init) */ + readonly declaringIn?: Ast.SourceLocation; } /** @@ -191,9 +200,12 @@ export namespace State { (read) => (state) => read(state.scopes), ); - export const push = () => + export const push = (range?: Ast.SourceLocation) => update((scopes) => ({ - stack: [...scopes.stack, { ssaVars: new Map(), usedNames: new Map() }], + stack: [ + ...scopes.stack, + { ssaVars: new Map(), usedNames: new Map(), range }, + ], })); export const pop = () => diff --git a/packages/bugc/src/irgen/generate/statements/block.ts b/packages/bugc/src/irgen/generate/statements/block.ts index 762b72a522..710b25f531 100644 --- a/packages/bugc/src/irgen/generate/statements/block.ts +++ b/packages/bugc/src/irgen/generate/statements/block.ts @@ -8,7 +8,7 @@ export const makeBuildBlock = ( buildStatement: (stmt: Ast.Statement) => Process, ) => function* buildBlock(block: Ast.Block): Process { - yield* Process.Variables.enterScope(); + yield* Process.Variables.enterScope(block.loc ?? undefined); for (const item of block.items) { if (Ast.isStatement(item)) { diff --git a/packages/bugc/src/irgen/generate/statements/control-flow.ts b/packages/bugc/src/irgen/generate/statements/control-flow.ts index f89fdb6ccd..aa3c78397f 100644 --- a/packages/bugc/src/irgen/generate/statements/control-flow.ts +++ b/packages/bugc/src/irgen/generate/statements/control-flow.ts @@ -131,9 +131,13 @@ const makeBuildLoop = ( }): Process { const buildBlock = makeBuildBlock(buildStatement); - // Execute init statement if present (for loops) + // Execute init statement if present (for loops). Its variables + // are in scope for the loop only (for debug info), as the + // typechecker scopes them. if (config.init) { + yield* Process.Variables.declaringIn(config.node?.loc ?? undefined); yield* buildStatement(config.init); + yield* Process.Variables.declaringIn(undefined); } // Create blocks diff --git a/packages/bugc/src/irgen/generate/statements/declare.ts b/packages/bugc/src/irgen/generate/statements/declare.ts index fc011901a6..9242c1e075 100644 --- a/packages/bugc/src/irgen/generate/statements/declare.ts +++ b/packages/bugc/src/irgen/generate/statements/declare.ts @@ -98,10 +98,20 @@ function* buildVariableDeclaration( } as Ir.Instruction); // Declare the SSA variable and directly use the allocTemp as its value + // With an initializer other than a hex literal, the local's value + // is the initializer's (below), not this allocation. + const builtLater = + !!decl.initializer && + !( + Ast.Expression.isLiteral(decl.initializer) && + Ast.Expression.Literal.isHex(decl.initializer) + ); yield* Process.Variables.declareWithExistingTemp( decl.name, irType, allocTemp, + decl.loc ?? undefined, + { placeholder: builtLater }, ); // If there's an initializer, store the value in memory @@ -112,7 +122,7 @@ function* buildVariableDeclaration( // For reference types, we need to handle initialization // Check the initializer type to determine how to store it - if (Ast.Expression.isLiteral(decl.initializer)) { + if (!builtLater) { const hexLiteral = decl.initializer as Ast.Expression.Literal; if (Ast.Expression.Literal.isHex(hexLiteral)) { const hexValue = hexLiteral.value.startsWith("0x") @@ -151,7 +161,7 @@ function* buildVariableDeclaration( } as Ir.Instruction.Write); } } else { - // For slice expressions and other bytes operations, + // For string literals, slice expressions and other bytes operations, // the value is already a reference to memory // We need to copy the slice result to the new allocation // This is a simplified version - a full implementation would need to @@ -183,7 +193,11 @@ function* buildVariableDeclaration( const value = yield* buildExpression(decl.initializer, { kind: "rvalue", }); - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); // Generate assignment to the new SSA temp if (value.kind === "temp") { @@ -207,7 +221,11 @@ function* buildVariableDeclaration( } } else { // No initializer - declare with default value - const ssaVar = yield* Process.Variables.declare(decl.name, irType); + const ssaVar = yield* Process.Variables.declare( + decl.name, + irType, + decl.loc ?? undefined, + ); yield* Process.Instructions.emit({ kind: "const", value: 0n, diff --git a/packages/bugc/src/optimizer/optimizer.ts b/packages/bugc/src/optimizer/optimizer.ts index 418e9cd2f6..ff52786161 100644 --- a/packages/bugc/src/optimizer/optimizer.ts +++ b/packages/bugc/src/optimizer/optimizer.ts @@ -228,7 +228,7 @@ export abstract class BaseOptimizationStep implements OptimizationStep { }); } - return { + const cloned: Ir.Function = { name: func.name, parameters: [...func.parameters], entry: func.entry, @@ -240,6 +240,11 @@ export abstract class BaseOptimizationStep implements OptimizationStep { ...(func.loc ? { loc: func.loc } : {}), ...(func.sourceId !== undefined ? { sourceId: func.sourceId } : {}), }; + if (func.ssaVariables) { + Ir.Utils.setDebugInfo(cloned, "ssaVariables", new Map(func.ssaVariables)); + } + if (func.origins) Ir.Utils.setDebugInfo(cloned, "origins", func.origins); + return cloned; } protected replaceInstruction( diff --git a/packages/bugc/src/optimizer/origins.ts b/packages/bugc/src/optimizer/origins.ts new file mode 100644 index 0000000000..2a556dd07d --- /dev/null +++ b/packages/bugc/src/optimizer/origins.ts @@ -0,0 +1,48 @@ +import * as Ir from "#ir"; + +/** + * Record where each instruction, terminator and variable version is + * before optimization, with each function's dominator tree. Debug + * info uses these to tell which version of a local is current at an + * optimized instruction, including versions the optimizer removed. + */ +export function recordOrigins(module: Ir.Module): void { + const functions = [ + module.main, + ...(module.create ? [module.create] : []), + ...module.functions.values(), + ]; + for (const func of functions) { + const at = (block: string, index: number): Ir.Function.Origin => ({ + function: func.name, + block, + index, + }); + // Out of the optimizer's sight, like the rest of the debug info + // (see setDebugInfo) + const idom = new Ir.Analysis.Statistics.Analyzer().analyze({ + ...module, + main: func, + }).dominatorTree; + Ir.Utils.setDebugInfo(func, "origins", new Map([[func.name, idom]])); + for (const [id, block] of func.blocks) { + block.instructions.forEach((inst, i) => { + inst.operationDebug = { ...inst.operationDebug, origin: at(id, i) }; + }); + block.terminator.operationDebug = { + ...block.terminator.operationDebug, + origin: at(id, block.instructions.length), + }; + } + const defs = Ir.Utils.defSites(func); + for (const [key, ssa] of func.ssaVariables ?? []) { + const def = ssa.defined ?? defs.get(ssa.temp ?? key); + if (def && !ssa.placeholder) { + func.ssaVariables!.set(key, { + ...ssa, + origin: at(def.block, def.index), + }); + } + } + } +} diff --git a/packages/bugc/src/optimizer/simple-optimizer.ts b/packages/bugc/src/optimizer/simple-optimizer.ts index 7c64642d70..de1f94ba4c 100644 --- a/packages/bugc/src/optimizer/simple-optimizer.ts +++ b/packages/bugc/src/optimizer/simple-optimizer.ts @@ -4,6 +4,7 @@ import * as Ir from "#ir"; import { OptimizationPipeline, type OptimizationStep } from "./optimizer.js"; +import { recordOrigins } from "./origins.js"; import { ConstantFoldingStep, DeadCodeEliminationStep, @@ -21,6 +22,7 @@ import { * Apply all optimizations based on the specified level */ export function optimizeIr(module: Ir.Module, level: number): Ir.Module { + recordOrigins(module); if (level === 0) return module; const steps = createOptimizationPipeline(level); diff --git a/packages/bugc/src/optimizer/steps/constant-propagation.ts b/packages/bugc/src/optimizer/steps/constant-propagation.ts index 1fb5f86008..261cea22fa 100644 --- a/packages/bugc/src/optimizer/steps/constant-propagation.ts +++ b/packages/bugc/src/optimizer/steps/constant-propagation.ts @@ -23,7 +23,13 @@ export class ConstantPropagationStep extends BaseOptimizationStep { let newInst = inst; // Track constant assignments and their debug info - if (inst.kind === "const" && "dest" in inst) { + // A string constant (a reference) is data that code + // generation puts in memory, not a value an operand can hold. + if ( + inst.kind === "const" && + "dest" in inst && + inst.type.kind !== "ref" + ) { constants.set(inst.dest, inst.value); if (inst.operationDebug) { constantDebug.set(inst.dest, inst.operationDebug); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index c7ed4c423f..42fb77801f 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -150,6 +150,9 @@ export class InliningStep extends BaseOptimizationStep { if (!idRename.has(id)) idRename.set(id, prefix + id); } + const inlineSites = (debug: Ir.Instruction.Debug | undefined) => + inlineSitesFor(prefix, debug); + const remapValue = (v: Ir.Value): Ir.Value => { if (v.kind !== "temp") return v; const sub = paramSubst.get(v.id); @@ -196,6 +199,7 @@ export class InliningStep extends BaseOptimizationStep { const instructions: Ir.Instruction[] = origBlock.instructions.map( (inst, idx) => { const cloned = remapInstruction(inst, remapValue, idRename); + const sites = inlineSites(inst.operationDebug); // Mark every inlined instruction for membership. cloned.operationDebug = Ir.Utils.addTransform( cloned.operationDebug, @@ -217,6 +221,7 @@ export class InliningStep extends BaseOptimizationStep { ); } } + cloned.operationDebug.inlineSites = sites; return cloned; }, ); @@ -254,6 +259,13 @@ export class InliningStep extends BaseOptimizationStep { } else { terminator = remapTerminator(t, remapValue, blockRename); } + terminator.operationDebug = { + ...terminator.operationDebug, + inlineSites: inlineSites(t.operationDebug), + ...(t.operationDebug?.origin + ? { origin: t.operationDebug.origin } + : {}), + }; caller.blocks.set(newId, { id: newId, @@ -265,6 +277,25 @@ export class InliningStep extends BaseOptimizationStep { }); } + // --- carry the callee's locals into the caller --- + // Each keeps the temp that now holds its value (a parameter's is + // the call's argument; none if that is a constant) and belongs to + // this inline site. + const variables = new Map(caller.ssaVariables); + for (const [key, ssa] of callee.ssaVariables ?? []) { + const temp = ssa.temp ?? key; + const arg = paramSubst.get(temp); + variables.set(prefix + key, { + ...ssa, + temp: arg + ? arg.kind === "temp" + ? arg.id + : undefined + : (idRename.get(temp) ?? temp), + inlineSite: prefix + (ssa.inlineSite ?? ""), + }); + } + // --- wire the single return value into the caller --- // Current eligibility guarantees exactly one return. Substitute the // call's dest temp with the (remapped) returned value across the @@ -272,9 +303,26 @@ export class InliningStep extends BaseOptimizationStep { if (call.dest) { const returns = collectReturns(callee, blockRename, remapValue); if (returns.length === 1) { - substituteTemp(caller, call.dest, returns[0].value); + const value = returns[0].value; + substituteTemp(caller, call.dest, value); + // A local the call's result was assigned to now has the + // returned value's temp (none if that is a constant). + const local = variables.get(call.dest); + if (local) { + variables.set(call.dest, { + ...local, + temp: value.kind === "temp" ? value.id : undefined, + }); + } } } + if (variables.size > 0) + Ir.Utils.setDebugInfo(caller, "ssaVariables", variables); + Ir.Utils.setDebugInfo( + caller, + "origins", + new Map([...(callee.origins ?? []), ...(caller.origins ?? [])]), + ); // --- rewire the calling block: call -> jump into inlined entry --- callBlock.terminator = { @@ -290,6 +338,18 @@ export class InliningStep extends BaseOptimizationStep { // ---- helpers ---- +/** + * The inline sites of an instruction once inlined through `prefix`: + * this site, then the sites it was already inlined through, renamed + * as its callee's locals are. + */ +function inlineSitesFor( + prefix: string, + debug: Ir.Instruction.Debug | undefined, +): string[] { + return [prefix, ...(debug?.inlineSites ?? []).map((s) => prefix + s)]; +} + function collectReturns( callee: Ir.Function, blockRename: Map, @@ -564,6 +624,7 @@ function mergeDiscriminator( if (existing && "gather" in existing && Array.isArray(existing.gather)) { // Add as a new gather child rather than a sibling of gather. return { + ...debug, context: { ...existing, gather: [...(existing.gather as unknown[]), { [key]: value }], @@ -571,6 +632,7 @@ function mergeDiscriminator( }; } return { + ...debug, context: { ...(existing ?? {}), [key]: value, @@ -596,6 +658,7 @@ function gatherCallSite( if ("gather" in existing && Array.isArray(existing.gather)) { // Already a gather — add the call site as another child. return { + ...debug, context: { gather: [callSite, ...(existing.gather as unknown[])], } as Format.Program.Context, @@ -604,11 +667,13 @@ function gatherCallSite( if ("code" in existing) { // Colliding `code` keys — gather both. return { + ...debug, context: { gather: [callSite, existing] } as Format.Program.Context, }; } // No existing `code` — compose flat. return { + ...debug, context: { ...existing, code: callSiteCode } as Format.Program.Context, }; } diff --git a/packages/bugc/test/evm/locals.ts b/packages/bugc/test/evm/locals.ts new file mode 100644 index 0000000000..4eac4997fc --- /dev/null +++ b/packages/bugc/test/evm/locals.ts @@ -0,0 +1,400 @@ +/** + * Support for tests of local-variable debug info: compile, run with a + * full trace, and read each listed local's pointer with + * @ethdebug/pointers `dereference` against the machine state after + * each step (contexts are postconditions). + */ +import { expect } from "vitest"; +import { compile } from "#compiler"; +import { Executor, createMachineState, type TraceStep } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import type * as Format from "@ethdebug/format"; + +export type Level = 0 | 1 | 2 | 3; + +export interface LocalsTrace { + program: Format.Program; + executor: Executor; + steps: TraceStep[]; + /** The program's instruction at a step's pc */ + instructionAt(step: TraceStep): Format.Program.Instruction | undefined; +} + +/** Compile (at O0 by default) and run once, keeping every step. */ +export async function traceLocals( + source: string, + level: Level = 0, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) throw new Error("compile failed"); + const bytecode = result.value.bytecode; + const executor = new Executor(); + await executor.deploy( + bytesToHex( + bytecode.create && bytecode.create.length > 0 + ? bytecode.create + : bytecode.runtime, + ), + ); + const steps: TraceStep[] = []; + await executor.execute({ data: "" }, (step) => steps.push(step)); + + const program = bytecode.runtimeProgram; + const byOffset = new Map( + program.instructions.map((i) => [Number(i.offset), i]), + ); + return { + program, + executor, + steps, + instructionAt: (step) => byOffset.get(step.pc), + }; +} + +/** The `variables` entries of a context. */ +export function localsOf(context: unknown): Array> { + if (!context || typeof context !== "object") return []; + const variables = (context as { variables?: unknown }).variables; + return Array.isArray(variables) + ? (variables as Array>) + : []; +} + +/** The layout of a reference-typed local, for reading its pointer */ +export type Shape = + | { kind: "bytes" } + | { kind: "array"; element: Shape | { kind: "scalar"; size: number } }; + +/** A reference's value as its regions read it: hex for bytes and + * strings, an array of bigints (or nested arrays) for arrays. */ +export type RefValue = string | bigint | RefValue[]; + +/** A value a local takes, and (optionally) the source text of the + * statement that gives it: the value may be read only once that + * statement has run (as many times as the value is listed after it). */ +export type Value = RefValue | { value: RefValue; after: string }; + +export interface Local { + /** The values the local takes, in execution order */ + values: Value[]; + /** A scalar's size in bytes (default 32) */ + size?: number; + /** A reference's layout */ + shape?: Shape; + /** Must be located at every level, not just at O0 */ + everyLevel?: boolean; +} + +export interface LocalsProgram { + name: string; + source: string; + /** + * By local: its name, or `name@text` for one of several + * declarations of a name, where `text` is source text within that + * declaration. + */ + locals: Record; + /** The levels to check at, if not all */ + levels?: Level[]; +} + +/** + * Run a program at a level and check every listed local: at each step + * that lists a local with a pointer, the state after the step must + * hold one of the local's values, read through the pointer region by + * region against memory: not earlier than the last value read or the + * latest one whose statement has finished, and not one whose statement + * has not run yet. A listed local the program does not expect fails. + * At O0 (or every level, if so marked), every local must be located at + * some step. + */ +export async function check( + { source, locals }: LocalsProgram, + level: Level, +): Promise { + const run = await traceLocals(source, level); + const { steps, executor } = run; + + // The declaration each key names: an offset in the source + const keys = Object.keys(locals).map((key) => { + const [name, text] = key.split("@"); + const at = text === undefined ? undefined : source.indexOf(text); + if (at !== undefined && at < 0) throw new Error(`no "${text}" in source`); + return { key, name, at }; + }); + const keyOf = (entry: Record): string | undefined => { + const range = ( + entry.declaration as { range?: { offset: number; length: number } } + )?.range; + return keys.find( + ({ name, at }) => + name === entry.identifier && + (at === undefined || + (range !== undefined && + at >= range.offset && + at < range.offset + range.length)), + )?.key; + }; + + // How often the statement giving each value has run so far + const statements = [ + ...new Set( + Object.values(locals).flatMap((local) => + local.values.flatMap((v) => + typeof v === "object" && v !== null && "after" in v ? [v.after] : [], + ), + ), + ), + ].map((text) => { + const offset = source.indexOf(text); + if (offset < 0) throw new Error(`no "${text}" in source`); + return { text, offset, end: offset + text.length }; + }); + // A statement runs while an instruction within it executes, and has + // finished a run when execution leaves it + const runs = new Map(); + const finished = new Map(); + let inside = new Set(); + + /** The values of a local, with the ordinal of each among the values + * its statement gives */ + const valuesOf = (local: Local) => { + const seen = new Map(); + return local.values.map((v) => { + const { value, after } = + typeof v === "object" && v !== null && "after" in v + ? v + : { value: v, after: undefined }; + const run = + after === undefined + ? 0 + : (seen.set(after, (seen.get(after) ?? 0) + 1), seen.get(after)!); + return { value, after, run }; + }); + }; + + const at: Record = {}; + for (let k = 0; k + 1 < steps.length; k++) { + const context = run.instructionAt(steps[k])?.context; + const ranges = codeRanges(context); + const now = new Set( + statements + .filter((s) => ranges.some(([o, e]) => o >= s.offset && e <= s.end)) + .map((s) => s.text), + ); + for (const text of now) { + if (!inside.has(text)) runs.set(text, (runs.get(text) ?? 0) + 1); + } + for (const text of inside) { + if (!now.has(text)) finished.set(text, (finished.get(text) ?? 0) + 1); + } + inside = now; + + for (const entry of localsOf(context)) { + const pointer = entry.pointer as Record | undefined; + // A storage variable (a local's pointer is never into storage) + if (!pointer || JSON.stringify(pointer).includes('"storage"')) continue; + const name = entry.identifier as string; + const key = keyOf(entry); + const where = `after ${steps[k].opcode} at pc ${steps[k].pc}`; + expect(key, `unexpected local ${name} ${where}`).toBeDefined(); + const local = locals[key!]; + + const read = local.shape + ? await readReference( + pointer, + name, + local.shape, + executor, + steps[k + 1], + ) + : await readScalar(pointer, name, executor, steps[k + 1]); + + // Never an earlier value than the last one read, nor than the + // latest one whose statement has finished (that would be stale); + // never a value whose statement has not run (that would be early) + const values = valuesOf(local); + const latest = values.reduce( + (last, { after, run }, i) => + after !== undefined && (finished.get(after) ?? 0) >= run ? i : last, + 0, + ); + const from = Math.max(at[key!] ?? 0, latest); + const index = values.findIndex(({ value, after, run }, i) => { + if (i < from) return false; + if (after !== undefined && (runs.get(after) ?? 0) < run) return false; + const expected = + local.shape || typeof value !== "bigint" + ? stringify(value) + : hexBytes(value, local.size ?? 32); + return expected === stringify(read); + }); + expect( + index, + `${key} reads ${stringify(read)} ${where}; expected ` + + values + .slice(from) + .map(({ value }) => stringify(value)) + .join(", "), + ).toBeGreaterThanOrEqual(0); + at[key!] = index; + } + } + + const required = Object.keys(locals).filter( + (key) => level === 0 || locals[key].everyLevel, + ); + expect( + required.filter((key) => !(key in at)), + `locals not located at O${level}`, + ).toEqual([]); +} + +/** The source ranges of a context's `code`, through `gather` */ +function codeRanges(context: unknown): Array<[number, number]> { + if (!context || typeof context !== "object") return []; + const { code, gather } = context as { + code?: { range?: { offset: number; length: number } }; + gather?: unknown[]; + }; + const range = code?.range; + return [ + ...(range ? [[range.offset, range.offset + range.length]] : []), + ...(Array.isArray(gather) ? gather.flatMap(codeRanges) : []), + ] as Array<[number, number]>; +} + +/** A value as the hex of `size` big-endian bytes. */ +export function hexBytes(value: bigint, size = 32): string { + return "0x" + value.toString(16).padStart(size * 2, "0"); +} + +/** Text as the hex of its UTF-8 bytes */ +export function textBytes(value: string): string { + return "0x" + Buffer.from(value).toString("hex"); +} + +function stringify(value: unknown): string { + return typeof value === "string" + ? value + : JSON.stringify(value, (_, v) => + typeof v === "bigint" ? hexBytes(v).replace(/^0x0*/, "0x") : v, + ); +} + +/** + * Dereference a pointer at a step and read its regions in order, + * checking each against the step's memory. + */ +async function regionsAt( + pointer: unknown, + executor: Executor, + step: TraceStep, +) { + const state = createMachineState(executor, { traceStep: step }); + const cursor = await dereference(pointer as Format.Pointer, { state }); + const view = await cursor.view(state); + const memory = step.memory ?? new Uint8Array(); + const regions = [...view.regions]; + let next = 0; + return { + /** Move to the region named `name` */ + seek(name: string) { + next = regions.findIndex((r) => r.name === name); + if (next < 0) throw new Error(`no region ${name}`); + }, + /** Read the next region, which must be named `name` (and, in + * memory, at `offset` and `size` bytes long, if given) */ + async read(name: string, offset?: bigint, size?: bigint) { + const region = regions[next++]; + if (!region || region.name !== name) { + throw new Error(`expected region ${name}, got ${region?.name}`); + } + const data = await view.read(region); + if (region.location === "memory") { + const start = region.offset!.asUint(); + const length = region.length!.asUint(); + if (offset !== undefined && start !== offset) { + throw new Error(`${name} at ${start}, expected ${offset}`); + } + if (size !== undefined && length !== size) { + throw new Error(`${name} is ${length} bytes, expected ${size}`); + } + const actual = new Uint8Array(Number(length)); + actual.set(memory.slice(Number(start), Number(start + length))); + if (data.toHex() !== "0x" + Buffer.from(actual).toString("hex")) { + throw new Error(`${name} does not read memory at ${start}`); + } + } + return data; + }, + }; +} + +/** A scalar local's bytes (as hex) at a step */ +async function readScalar( + pointer: unknown, + name: string, + executor: Executor, + step: TraceStep, +): Promise { + const regions = await regionsAt(pointer, executor, step); + regions.seek(name); + return (await regions.read(name)).toHex(); +} + +/** + * A reference-typed local's value at a step, by its layout: the + * local's word holds the base address, the length word is at the base, + * each element is in its word (a scalar right-aligned), and the data + * follows the length word, as long as the length says. + */ +async function readReference( + pointer: unknown, + name: string, + shape: Shape, + executor: Executor, + step: TraceStep, +): Promise { + const regions = await regionsAt(pointer, executor, step); + regions.seek(name); + + const decode = async ( + shape: Shape, + prefix: string, + base: bigint, + ): Promise => { + const length = (await regions.read(`${prefix}-length`, base, 32n)).asUint(); + if (shape.kind === "bytes") { + return (await regions.read(`${prefix}-data`, base + 32n, length)).toHex(); + } + const element = shape.element; + const values: RefValue[] = []; + for (let i = 0n; i < length; i++) { + const word = base + 32n + 32n * i; + if (element.kind === "scalar") { + const size = BigInt(element.size); + const value = await regions.read( + `${prefix}-element`, + word + 32n - size, + size, + ); + values.push(value.asUint()); + } else { + const address = ( + await regions.read(`${prefix}-element`, word, 32n) + ).asUint(); + values.push(await decode(element, `${prefix}-element`, address)); + } + } + return values; + }; + + const base = (await regions.read(name)).asUint(); + return decode(shape, name, base); +} diff --git a/packages/bugc/test/examples/examples.test.ts b/packages/bugc/test/examples/examples.test.ts index e6b344005a..f82b0d3554 100644 --- a/packages/bugc/test/examples/examples.test.ts +++ b/packages/bugc/test/examples/examples.test.ts @@ -206,42 +206,48 @@ describe("Example Files", async () => { const itFn = skip ? it.skip : it; for (const level of [0, 1, 2, 3] as const) { - itFn(`${relativePath} at O${level}`, async () => { - const compiler = buildSequence(bytecodeSequence); - const result = await compiler.run({ - source, - optimizer: { level }, - }); - if (!result.success) { - expect.fail(`Compilation failed at O${level}`); - } + itFn( + `${relativePath} at O${level}`, + async () => { + const compiler = buildSequence(bytecodeSequence); + const result = await compiler.run({ + source, + optimizer: { level }, + }); + if (!result.success) { + expect.fail(`Compilation failed at O${level}`); + } - const { runtimeProgram, createProgram } = result.value.bytecode; - const storageNames = (result.value.ast.storage ?? []).map( - ({ name }) => name, - ); - for (const program of [runtimeProgram, createProgram]) { - if (!program) continue; - // validate the program as it is written out, as JSON - const output = await validate( - "schema:ethdebug/format/program", - JSON.parse(JSON.stringify(program)), - BASIC, + const { runtimeProgram, createProgram } = result.value.bytecode; + const storageNames = (result.value.ast.storage ?? []).map( + ({ name }) => name, ); - const invalid = output.valid - ? [] - : (output.errors ?? []).map((e) => e.instanceLocation); - expect(invalid).toEqual([]); - - // every storage variable is in the program-level context - const { context } = program; - const identifiers = - context && Format.Program.Context.isVariables(context) - ? context.variables.map(({ identifier }) => identifier) - : []; - expect(identifiers).toEqual(storageNames); - } - }); + for (const program of [runtimeProgram, createProgram]) { + if (!program) continue; + // validate the program as it is written out, as JSON + const output = await validate( + "schema:ethdebug/format/program", + JSON.parse(JSON.stringify(program)), + BASIC, + ); + const invalid = output.valid + ? [] + : (output.errors ?? []).map((e) => e.instanceLocation); + expect(invalid).toEqual([]); + + // every storage variable is in the program-level context + const { context } = program; + const identifiers = + context && Format.Program.Context.isVariables(context) + ? context.variables.map(({ identifier }) => identifier) + : []; + expect(identifiers).toEqual(storageNames); + } + // A program lists its locals at every instruction, so a long + // one takes a while to validate + }, + 10_000, + ); } } }); diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index 26e6a315c9..ad7e569a85 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -313,6 +313,13 @@ export function collectVariablesWithLocations( } ``` +BUG also lists the local variables (parameters and `let`s) in scope at each +instruction, each with a pointer to its value where the value is located: a +memory word in the function's frame, or a stack slot. A local hides a storage +variable of the same name while it is in scope. Under optimization, a local +whose value the optimizer folded to a constant or removed is listed without a +pointer. See `packages/bugc/src/evmgen/debug/local-variables.ts`. + ## Testing strategy BUG tests debug output in several ways: @@ -379,8 +386,6 @@ failing entirely. Areas for improvement in BUG's debug support: -- **Memory tracking** — Track memory allocations for local variable pointers -- **Stack variables** — Generate stack pointers during code generation - **Richer contexts** — Add frame information for function calls - **Source maps** — More granular source location tracking