From 4ddbc7ccec683d19aaa17937314394aaa6a0f9ba Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 26 Sep 2026 04:55:35 +0200 Subject: [PATCH] refactored some more stuff for headless --- extension/scripts/survey.js | 49 +++++ src/{ide => common}/breakcond.ts | 0 src/common/breakpoints.ts | 97 +++++++++ src/common/emucore.ts | 341 ++++++++++++++++++++++++++++++ src/common/symbols/symbolfile.ts | 28 +++ src/ide/breakpoints.ts | 97 ++------- src/ide/ui.ts | 2 +- src/tools/8bws.ts | 3 +- src/tools/emutarget.ts | 343 +------------------------------ src/tools/runscript.ts | 20 +- test/cli/testcli.js | 4 + test/unit/testbreakpoints.ts | 61 +++++- 12 files changed, 611 insertions(+), 434 deletions(-) create mode 100644 extension/scripts/survey.js rename src/{ide => common}/breakcond.ts (100%) create mode 100644 src/common/breakpoints.ts create mode 100644 src/common/emucore.ts create mode 100644 src/common/symbols/symbolfile.ts diff --git a/extension/scripts/survey.js b/extension/scripts/survey.js new file mode 100644 index 00000000..4f805cd2 --- /dev/null +++ b/extension/scripts/survey.js @@ -0,0 +1,49 @@ +// Starts each platform in out/emuworker.js and reports whether frames arrive. +// usage: node scripts/survey.js [platform...] (after npm run build) +const { Worker } = require('worker_threads'); +const fs = require('fs'), path = require('path'); +const ROOT = path.resolve(__dirname, '../..'); +const OUT = ROOT + '/extension/out'; +function rpcWorker(file) { + const w = new Worker(path.join(OUT, file), { workerData: { rootDir: ROOT }, stdout: true, stderr: true }); + let id = 1; const pending = new Map(); const events = []; + w.on('message', m => { + if (m.t === 'result') { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.value); } + else if (m.t === 'event') events.push(m); + }); + w.on('error', e => { for (const p of pending.values()) p.rej(e); }); + return { w, events, call: (method, ...args) => new Promise((res, rej) => { const i = id++; pending.set(i, { res, rej }); w.postMessage({ t: 'call', id: i, method, args }); }) }; +} +function romFor(id) { + for (const d of [id, id.split('-')[0], id.split('.')[0]]) { + const dir = path.join(ROOT, 'test/roms', d); + if (fs.existsSync(dir)) { const f = fs.readdirSync(dir).filter(f => /\.rom$|\.bin$/.test(f))[0]; if (f) return { rom: new Uint8Array(fs.readFileSync(path.join(dir, f))), name: d + '/' + f }; } + } + return { rom: new Uint8Array(+(process.env.ROMSIZE || 0x8000)), name: 'zeros' }; +} +const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms))]); +(async () => { + const b = rpcWorker('buildworker.js'); + const ids = process.argv[2] ? process.argv.slice(2) : await b.call('listPlatforms'); + await b.w.terminate(); + for (const id of ids) { + const { rom, name } = romFor(id); + const e = rpcWorker('emuworker.js'); + let result; + try { + await withTimeout(e.call('start', id, rom), 8000); + await new Promise(r => setTimeout(r, 1500)); + const frames = e.events.filter(x => x.name === 'frame'); + const halted = e.events.find(x => x.name === 'status' && x.data?.state === 'halted'); + if (halted) result = 'HALTED: ' + halted.data.message; + else if (!frames.length) result = 'NO FRAMES'; + else { + const px = new Uint32Array(frames[frames.length-1].data.pixels); + result = `ok ${frames.length} frames ${frames[0].data.width}x${frames[0].data.height}` + (px.some(p => p !== px[0]) ? '' : ' (blank)'); + } + } catch (err) { result = 'FAIL: ' + String(err.message).split('\n')[0].slice(0, 140); } + console.log(`${id}\t${name}\t${result}`); + await e.w.terminate().catch(() => {}); + } + process.exit(0); +})(); diff --git a/src/ide/breakcond.ts b/src/common/breakcond.ts similarity index 100% rename from src/ide/breakcond.ts rename to src/common/breakcond.ts diff --git a/src/common/breakpoints.ts b/src/common/breakpoints.ts new file mode 100644 index 00000000..846be0c1 --- /dev/null +++ b/src/common/breakpoints.ts @@ -0,0 +1,97 @@ +// Breakpoints and how they resolve to an address, shared by every host (the +// IDE, the CLI, the VS Code debug adapter). Two kinds: +// - 'source' -- file + line, resolved to a PC via the build listing +// - 'address' -- a target that's a symbol, $hex or decimal address +// Both support an optional condition expression (see breakcond.ts). +// The IDE's store (persistence, gutter markers) is in src/ide/breakpoints.ts. + +import type { Platform, SymbolMap } from "./baseplatform"; +import type { CodeListing } from "./workertypes"; +import { CondFn, CondContext, compileCondition, parseTarget } from "./breakcond"; +import { lookupSymbol } from "./symbols/symbolfile"; + +export interface Breakpoint { + id: number; + type: 'source' | 'address'; + file?: string; // source: project path + line?: number; // source: 1-based line number + target?: string; // address: symbol, $hex or decimal + enabled: boolean; + condition?: string; +} + +export interface ResolvedBreakpoint { + bp: Breakpoint; + pc?: number; // resolved address (undefined if not resolvable now) + condFn?: CondFn; + error?: string; // why the breakpoint can't resolve right now +} + +// What resolving a breakpoint needs from the host. +export interface BreakpointContext { + /** name -> address, from the build or a label file */ + symbols?: SymbolMap; + /** the build's listing for a source file, for source breakpoints */ + getListingForFile?(path: string): CodeListing | undefined; + /** CPU fields, memory and raster position, for conditions */ + platform?: Platform; +} + +function makeCondContext(ctx: BreakpointContext): CondContext { + let platform = ctx.platform; + let cpuFields = new Set(); + try { + let c = platform && platform.getCPUState && platform.getCPUState(); + if (c) cpuFields = new Set(Object.keys(c)); + } catch (e) { + } + // platform accessors reachable with the '#' sigil; the value is read live + // at eval time so it reflects the current raster position, etc. + let hw: { [name: string]: () => number | undefined } = null; + if (platform) { + if (platform.getRasterScanline) { + hw = hw || {}; + hw['scanline'] = () => platform.getRasterScanline(); + } + if (platform.getRasterLineClock) { + hw = hw || {}; + hw['lineclock'] = () => platform.getRasterLineClock(); + } + } + return { + cpuFields, + symbol: (name) => lookupSymbol(ctx.symbols, name), + readMem: platform && platform.readAddress ? (a) => platform.readAddress(a) : undefined, + readVRAM: platform && platform.readVRAMAddress ? (a) => platform.readVRAMAddress(a) : undefined, + hw, + }; +} + +// Can this platform actually stop at a breakpoint? +export function canUseBreakpoints(platform: Platform): boolean { + return !!(platform && (platform.runEval || platform.runToPC || platform.runEvalAtPC)); +} + +export function resolveBreakpoint(bp: Breakpoint, ctx: BreakpointContext): ResolvedBreakpoint { + try { + let pc: number; + if (bp.type == 'source') { + let lst = ctx.getListingForFile && ctx.getListingForFile(bp.file); + let sf = lst && (lst.sourcefile || lst.assemblyfile); + if (!sf) return { bp, error: "no debug info (build first?)" }; + pc = sf.line2offset.get(bp.line); + if (!(pc >= 0)) return { bp, error: "line has no code" }; + } else { + let r = parseTarget(bp.target, (name) => lookupSymbol(ctx.symbols, name)); + if (r.error) return { bp, error: r.error }; + pc = r.pc; + } + let condFn: CondFn = undefined; + if (bp.condition && bp.condition.trim()) { + condFn = compileCondition(bp.condition, makeCondContext(ctx)); + } + return { bp, pc, condFn }; + } catch (e) { + return { bp, error: String(e.message || e) }; + } +} diff --git a/src/common/emucore.ts b/src/common/emucore.ts new file mode 100644 index 00000000..b16b5408 --- /dev/null +++ b/src/common/emucore.ts @@ -0,0 +1,341 @@ +// Unified headless driver for the emulators in this codebase. +// +// A *Platform* (src/platform/*) is the full object the IDE drives: it owns its +// own video/audio/timer and implements the Platform interface in +// common/baseplatform.ts. Most platforms are built on a *Machine* (src/machine/*), +// the bare emulation core made of the interfaces in common/devices.ts -- Bus, +// FrameBased, HasCPU, VideoSource and friends. +// +// EmuCore drives a Platform, reaching through to its Machine when there is +// one for the things only the core can do (exact traps, single stepping, the +// probe). The CLI and the run-script interpreter never have to ask which of +// those a target supports; the capability sniffing lives here. +// +// No DOM and no Node: start() swaps in headless video for the platform, and a +// host that already started the platform (the IDE) can wrap it without +// calling start(). The Node side (mocks, loading platform modules) is in +// src/tools/emutarget.ts. + +import { + CpuState, DisasmLine, EmuState, Machine, Platform, hasProbe, isDebuggable, +} from "./baseplatform"; +import { ProbeAll, TrapCondition } from "./devices"; +import { disassemble6502 } from "./cpu/disasm6502"; +import { disassembleZ80 } from "./cpu/disasmz80"; +import { disassembleSM83 } from "./cpu/disasmSM83"; +import { disassembleF8 } from "./cpu/disasmF8"; +import { disassembleHuC6280 } from "./cpu/disasmHuC6280"; +import { CPU6809 } from "./cpu/6809"; +import * as emu from "./emu"; + +export interface VideoOutput { + pixels: Uint32Array; + width: number; + height: number; + /** display rotation in degrees (RasterVideo's `rotate` option) */ + rotate?: number; + /** display aspect ratio, if not width/height */ + aspect?: number; +} + +export interface DebugSection { + category: string; + text: string; +} + +/** Disassemblers, keyed by the `arch` name used in the worker's platform params. */ +export const DISASSEMBLERS: { [arch: string]: (addr: number, read: (a: number) => number) => DisasmLine } = { + '6502': (a, r) => disassemble6502(a, r(a), r(a + 1), r(a + 2)), + 'huc6280': (a, r) => disassembleHuC6280(a, r(a), r(a + 1), r(a + 2)), + 'z80': (a, r) => disassembleZ80(a, r(a), r(a + 1), r(a + 2), r(a + 3)), + 'gbz80': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)), + 'sm83': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)), + 'f8': (a, r) => disassembleF8(a, r(a), r(a + 1), r(a + 2)), + '6809': (a, r) => Object.create(CPU6809()).disasm(r(a), r(a + 1), r(a + 2), r(a + 3), r(a + 4), a), +}; + +/** CPU class name -> arch, for the platforms that don't implement disassemble(). */ +const CPU_ARCH: { [cls: string]: string } = { + 'MOS6502': '6502', 'HuC6280': 'huc6280', + 'Z80': 'z80', 'ZilogZ80': 'z80', + 'SM83': 'sm83', 'F8CPU': 'f8', 'CPU6809': '6809', +}; + +/** Work out which disassembler a CPU object wants. */ +function archOf(cpu: any): string { + const arch = CPU_ARCH[cpu?.constructor?.name]; + if (arch) return arch; + // CPU6809 is a factory returning a plain object, so it has no class name to + // match; it is also the only CPU here that carries its own disassembler. + if (typeof cpu?.disasm === 'function') return '6809'; + return ''; +} + +// Frames to run before giving up on a `runUntil` predicate that never fires. +export const DEFAULT_MAX_FRAMES = 1000; + +// Upper bound on clocks in one instruction, so stepping can't spin forever. +const MAX_CYCLES_PER_INSN = 64; + +/** + * Headless stand-ins for RasterVideo/VectorVideo/AnimationTimer. Platform + * modules pick these up because Platform.start() reads them off the emu module. + */ +function installHeadlessVideo() { + // Platform.start() builds its video/timer by reading these classes off the + // emu module, so we swap in headless stand-ins for the duration of start() + // and put the real ones back afterwards. Leaving the stubs installed would + // leak into every other module sharing the (cached) emu import -- which is + // exactly what happens when mocha reuses a worker across test files. + let pixels: Uint32Array | null = null; + let params: { width: number, height: number, rotate?: number, aspect?: number } | null = null; + let frameRate = 60; + // the handler a platform registers on its canvas; it's how the IDE + // delivers keys, whether or not there's a Machine behind the platform + let keyHandler: ((key: number, code: number, flags: number) => void) | null = null; + const setKeyboardEvents = function (callback) { keyHandler ??= callback; }; + const RasterVideo: any = function (_el: any, width: number, height: number, options?: { rotate?: number, aspect?: number }) { + const buffer = new ArrayBuffer(width * height * 4); + const datau8 = new Uint8Array(buffer); + const datau32 = new Uint32Array(buffer); + // the first one is the screen; later ones are debug views (nes nametables) + if (!pixels) { + params = { width, height, rotate: options?.rotate, aspect: options?.aspect }; + pixels = datau32; + } + this.create = function () { this.width = width; this.height = height; }; + this.setKeyboardEvents = setKeyboardEvents; + this.getFrameData = function () { return datau32; }; + this.getImageData = function () { return { data: datau8, width, height }; }; + this.updateFrame = function () { }; + this.clearRect = function () { }; + this.setupMouseEvents = function () { }; + this.canvas = this; + this.getContext = function () { return this; }; + this.fillRect = function () { }; + this.fillStyle = ''; + this.putImageData = function () { }; + this.style = {}; + }; + const VectorVideo: any = function () { + this.create = function () { this.drawops = 0; }; + this.setKeyboardEvents = setKeyboardEvents; + this.clear = function () { }; + this.drawLine = function () { this.drawops++; }; + }; + const AnimationTimer: any = function (fps: number) { + if (fps > 0) frameRate = fps; + this.running = false; + this.start = function () { }; + this.stop = function () { }; + this.isRunning = function () { return this.running; }; + }; + const original = emu.setVideoClasses({ RasterVideo, VectorVideo, AnimationTimer }); + return { + get(): VideoOutput | null { + return pixels && params ? { pixels, ...params } : null; + }, + get frameRate() { return frameRate; }, + get keyHandler() { return keyHandler; }, + restore() { + emu.setVideoClasses(original); + } + }; +} + +export class EmuCore { + frameCount = 0; + private video: ReturnType | null = null; + + constructor(readonly id: string, readonly platform: Platform) { + } + + /** + * The underlying Machine, if there is one. Platforms built on + * BaseMachinePlatform expose theirs, which is what lets us stop mid-frame + * through the TrapCondition the FrameBased interface already defines, + * instead of guessing at cycle counts. + */ + get machine(): Machine | null { + return (this.platform as any).machine || null; + } + + async start() { + // start() is where platforms construct their video and timer, so install + // the headless stand-ins just for that call, then restore the real classes. + const headless = installHeadlessVideo(); + this.video = headless; + try { + await this.platform.start(); + } finally { + headless.restore(); + } + } + reset() { this.platform.reset(); } + loadROM(data: Uint8Array, title = 'ROM') { this.platform.loadROM(title, data); } + + loadBIOS(data: Uint8Array, title = 'BIOS'): boolean { + if (!this.platform.loadBIOS) return false; + this.platform.loadBIOS(title, data); + return true; + } + + read(addr: number): number { + if (this.platform.readAddress) return this.platform.readAddress(addr); + const m = this.machine; + if (m) return m.readConst ? m.readConst(addr) : m.read(addr); + throw new Error(`platform '${this.id}' cannot read memory`); + } + + getCPUState(): CpuState | null { + try { return this.platform.getCPUState ? this.platform.getCPUState() : null; } + catch (e) { return null; } + } + + getPC(): number | null { + // Platforms may correct the PC (see debugPCDelta), so prefer getPC(). + try { if (this.platform.getPC) return this.platform.getPC(); } catch (e) { } + const s = this.getCPUState(); + return s ? s.PC : null; + } + + disassemble(addr: number): DisasmLine | null { + const read = (a: number) => this.read(a); + try { + if (this.platform.disassemble) return this.platform.disassemble(addr, read); + // a few platforms leave it out; fall back to the machine's CPU + const disasm = DISASSEMBLERS[archOf(this.machine?.cpu)]; + return disasm ? disasm(addr, read) : null; + } catch (e) { return null; } + } + + setKeyInput(key: number, code: number, flags: number) { + const handler = this.video?.keyHandler; + if (handler) return handler(key, code, flags); + const target: any = this.machine || this.platform; + if (typeof target.setKeyInput !== 'function') { + throw new Error(`platform '${this.id}' does not accept key input`); + } + target.setKeyInput(key, code, flags); + } + + connectProbe(probe: ProbeAll | null): boolean { + const m = this.machine; + if (!m || !hasProbe(m)) return false; + m.connectProbe(probe); + return true; + } + + getVideo(): VideoOutput | null { return this.video ? this.video.get() : null; } + /** Frames per second the platform's timer asked for (60 if unknown). */ + get frameRate(): number { return this.video ? this.video.frameRate : 60; } + saveState(): EmuState | null { return this.platform.saveState ? this.platform.saveState() : null; } + + getDebugInfo(): DebugSection[] { + const state = this.saveState(); + const p: any = this.platform; + if (!state || !isDebuggable(p) || !p.getDebugCategories) return []; + const sections: DebugSection[] = []; + for (const category of p.getDebugCategories() || []) { + try { + const text = p.getDebugInfo(category, state); + if (text) sections.push({ category, text }); + } catch (e) { /* a category that doesn't apply to this state */ } + } + return sections; + } + + //// execution control, driven by what the target turns out to support + + /** True if execution can be stopped mid-frame at an exact instruction. */ + get supportsTrap(): boolean { return this.machine != null; } + + /** True if single instructions can be stepped. */ + get supportsStep(): boolean { return this.supportsTrap; } + + /** + * A function that advances the CPU by the smallest amount the machine + * supports -- one clock for ClockBased CPUs, one instruction for + * InstructionBased ones -- without disturbing the frame loop. Null if the + * machine only knows how to run whole frames. + */ + private clockStepper(): (() => void) | null { + const m = this.machine as any; + if (!m) return null; + if (typeof m.advanceCPU === 'function') return () => m.advanceCPU(); // BasicHeadlessMachine + if (typeof m.advanceFrameClock === 'function') return () => m.advanceFrameClock(null, 1); // WASM + return null; + } + + /** + * Finish any partly-executed instruction, so getPC() names a real + * instruction. Frames can end mid-instruction on clock-based CPUs. + */ + settle(): void { + const clock = this.clockStepper(); + if (!clock) return; + for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock(); + } + + advanceFrame(trap?: TrapCondition | null): void { + const m = this.machine; + // With a trap we drive the Machine directly -- Platform.advance() only + // honors traps registered as breakpoints, and installing/removing those + // rewinds BaseDebugPlatform's saved state. + if (trap && m) { + m.advanceFrame(trap); + } else { + const p = this.platform as any; + if (p.nextFrame) p.nextFrame(); + else if (p.advance) p.advance(false); + } + this.frameCount++; + } + + isStable(): boolean { + return this.machine ? this.machine.cpu.isStable() : true; + } + + /** Execute exactly one instruction. Returns false if the target can't step. */ + stepInsn(): boolean { + if (!this.machine) return false; + const clock = this.clockStepper(); + if (clock) { + this.settle(); // don't count an in-flight instruction as this step + clock(); + for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock(); + return true; + } + // No clock-level entry point: stop at the next instruction boundary via + // the frame trap. This restarts the frame, so it is the last resort. + let boundaries = 0; + for (let frames = 0; frames < 2 && boundaries < 2; frames++) { + this.advanceFrame(() => this.isStable() && ++boundaries >= 2); + } + return true; + } + + /** + * Run until `pred` is true, up to `maxFrames` frames. Exact to the + * instruction on targets with a Machine; frame-granular otherwise. + */ + runUntil(pred: () => boolean, maxFrames = DEFAULT_MAX_FRAMES): boolean { + const start = this.frameCount; + while (this.frameCount - start < maxFrames) { + if (this.supportsTrap) { + let hit = false; + this.advanceFrame(() => (hit = this.isStable() && pred())); + if (hit) return true; + } else { + this.advanceFrame(); + if (pred()) return true; + } + } + return false; + } + + /** Run until the PC reaches one of `addrs`. */ + runToPC(addrs: Set, maxFrames = DEFAULT_MAX_FRAMES): boolean { + return this.runUntil(() => addrs.has(this.getPC()), maxFrames); + } +} diff --git a/src/common/symbols/symbolfile.ts b/src/common/symbols/symbolfile.ts new file mode 100644 index 00000000..2af4166d --- /dev/null +++ b/src/common/symbols/symbolfile.ts @@ -0,0 +1,28 @@ + +import type { SymbolMap } from "../baseplatform"; + +/** Parse a cc65/ca65 or VICE label file into a symbol map. */ +export function parseSymbolFile(text: string): SymbolMap { + const symbols: SymbolMap = {}; + for (const line of text.split(/\r?\n/)) { + const m1 = line.match(/^\s*([A-Za-z_][\w]*)\s*=\s*\$?([0-9A-Fa-f]+)\s*;/); // ca65/cc65 list + const m2 = line.match(/^\s*(?:al|add_label)\s+([0-9A-Fa-f]+)\s+\.?([A-Za-z_][\w]*)/); // VICE + if (m1) symbols[m1[1]] = parseInt(m1[2], 16); + else if (m2) symbols[m2[2]] = parseInt(m2[1], 16); + } + return symbols; +} + +/** + * Look up a symbol the way a user types it: exactly, then with the leading + * underscore C compilers add (`main` finds `_main`), then without a leading + * '.' (VICE labels). + */ +export function lookupSymbol(symbols: SymbolMap | null | undefined, name: string): number | undefined { + if (!symbols) return undefined; + if (name in symbols) return symbols[name]; + if ('_' + name in symbols) return symbols['_' + name]; + const bare = name.replace(/^\./, ''); + if (bare !== name && bare in symbols) return symbols[bare]; + return undefined; +} diff --git a/src/ide/breakpoints.ts b/src/ide/breakpoints.ts index 1d81cafd..5538e6b2 100644 --- a/src/ide/breakpoints.ts +++ b/src/ide/breakpoints.ts @@ -1,30 +1,14 @@ -// Unified breakpoint store, shared by the editor gutter markers and the -// Breakpoints window. Two kinds of breakpoints: -// - 'source' -- file + line, resolved to a PC via the build listing -// (these render as gutter markers in the editor) -// - 'address' -- a target that's a symbol, $hex or decimal address -// Both support an optional condition expression (see breakcond.ts). -// The store persists to localStorage, scoped by platform + main file. +// The IDE's breakpoint store, shared by the editor gutter markers and the +// Breakpoints window. It persists to localStorage, scoped by platform + main +// file. Breakpoint types and resolution are in src/common/breakpoints.ts. import { current_project, platform } from "./ui"; -import { CondFn, CondContext, compileCondition, parseTarget } from "./breakcond"; +import { + Breakpoint, BreakpointContext, ResolvedBreakpoint, + canUseBreakpoints as platformCanUseBreakpoints, resolveBreakpoint as resolveIn, +} from "../common/breakpoints"; -export interface Breakpoint { - id: number; - type: 'source' | 'address'; - file?: string; // source: project path - line?: number; // source: 1-based line number - target?: string; // address: symbol, $hex or decimal - enabled: boolean; - condition?: string; -} - -export interface ResolvedBreakpoint { - bp: Breakpoint; - pc?: number; // resolved address (undefined if not resolvable now) - condFn?: CondFn; - error?: string; // why the breakpoint can't resolve right now -} +export type { Breakpoint, ResolvedBreakpoint }; const STORAGE_KEY = '8bitworkshop.breakpoints.v1'; @@ -145,73 +129,24 @@ class BreakpointStore { export const bpStore = new BreakpointStore(); -function makeSymbolLookup(): (name: string) => number | undefined { - return (name: string): number | undefined => { - let sm = platform && platform.debugSymbols && platform.debugSymbols.symbolmap; - if (!sm) return undefined; - // C symbols often get a leading underscore from the compiler - return (name in sm) ? sm[name] : sm['_' + name]; - }; -} - -function makeCondContext(): CondContext { - let cpuFields = new Set(); - try { - let c = platform && platform.getCPUState && platform.getCPUState(); - if (c) cpuFields = new Set(Object.keys(c)); - } catch (e) { - } - // platform accessors reachable with the '#' sigil; the value is read live - // at eval time so it reflects the current raster position, etc. - let hw: { [name: string]: () => number | undefined } = null; - if (platform) { - if (platform.getRasterScanline) { - hw = hw || {}; - hw['scanline'] = () => platform.getRasterScanline(); - } - if (platform.getRasterLineClock) { - hw = hw || {}; - hw['lineclock'] = () => platform.getRasterLineClock(); - } - } +function ideContext(): BreakpointContext { return { - cpuFields, - symbol: makeSymbolLookup(), - readMem: platform && platform.readAddress ? (a) => platform.readAddress(a) : undefined, - readVRAM: platform && platform.readVRAMAddress ? (a) => platform.readVRAMAddress(a) : undefined, - hw, + symbols: platform && platform.debugSymbols && platform.debugSymbols.symbolmap, + getListingForFile: (path) => current_project && current_project.getListingForFile(path), + platform, }; } // Can this platform actually stop at a breakpoint? export function canUseBreakpoints(): boolean { - return !!(platform && (platform.runEval || platform.runToPC || platform.runEvalAtPC)); + return platformCanUseBreakpoints(platform); } export function resolveBreakpoint(bp: Breakpoint): ResolvedBreakpoint { - try { - let pc: number; - if (bp.type == 'source') { - let lst = current_project.getListingForFile(bp.file); - let sf = lst && (lst.sourcefile || lst.assemblyfile); - if (!sf) return { bp, error: "no debug info (build first?)" }; - pc = sf.line2offset.get(bp.line); - if (!(pc >= 0)) return { bp, error: "line has no code" }; - } else { - let r = parseTarget(bp.target, makeSymbolLookup()); - if (r.error) return { bp, error: r.error }; - pc = r.pc; - } - let condFn: CondFn = undefined; - if (bp.condition && bp.condition.trim()) { - condFn = compileCondition(bp.condition, makeCondContext()); - } - return { bp, pc, condFn }; - } catch (e) { - return { bp, error: String(e.message || e) }; - } + return resolveIn(bp, ideContext()); } export function resolveBreakpoints(): ResolvedBreakpoint[] { - return bpStore.getAll().map(bp => resolveBreakpoint(bp)); + let ctx = ideContext(); + return bpStore.getAll().map(bp => resolveIn(bp, ctx)); } diff --git a/src/ide/ui.ts b/src/ide/ui.ts index 92cd5874..c6217032 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -38,7 +38,7 @@ import { ProjectWindows } from "./windows"; import { setupSplits, showTab } from "./layout"; export { setupSplits, showTab }; import { bpStore, canUseBreakpoints, resolveBreakpoints } from "./breakpoints"; -import { CondFn } from "./breakcond"; +import { CondFn } from "../common/breakcond"; import { findListingLocation as findListingLocationPure } from "./search/listinglocation"; import DOMPurify = require("dompurify"); diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index 8a95caf1..a3fa337b 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -16,7 +16,8 @@ import * as path from 'path'; import { isProbablyBinary } from '../common/util'; import { fail, hasOutput, note, output, setJsonMode } from './cliformat'; import { EmuTarget, loadPlatform } from './emutarget'; -import { RUN_SCRIPT_HELP, RunScript, parseNum, parseSymbolFile } from './runscript'; +import { RUN_SCRIPT_HELP, RunScript, parseNum } from './runscript'; +import { parseSymbolFile } from '../common/symbols/symbolfile'; import type { CompileResult } from './testlib'; interface Args { diff --git a/src/tools/emutarget.ts b/src/tools/emutarget.ts index d48126b9..d1d6598f 100644 --- a/src/tools/emutarget.ts +++ b/src/tools/emutarget.ts @@ -1,343 +1,16 @@ -// Unified headless driver for the emulators in this codebase. -// -// A *Platform* (src/platform/*) is the full object the IDE drives: it owns its -// own video/audio/timer and implements the Platform interface in -// common/baseplatform.ts. Most platforms are built on a *Machine* (src/machine/*), -// the bare emulation core made of the interfaces in common/devices.ts -- Bus, -// FrameBased, HasCPU, VideoSource and friends. -// -// EmuTarget drives a Platform headlessly, reaching through to its Machine when -// there is one for the things only the core can do (exact traps, single -// stepping, the probe). The CLI and the run-script interpreter never have to -// ask which of those a target supports; the capability sniffing lives here. +// The Node side of headless emulation: browser mocks and platform loading. +// The emulator driver itself is EmuCore (src/common/emucore.ts); EmuTarget is +// the name the CLI and the extension know it by. -import { - CpuState, DisasmLine, EmuState, Machine, Platform, hasProbe, isDebuggable, -} from "../common/baseplatform"; -import { ProbeAll, TrapCondition } from "../common/devices"; -import { disassemble6502 } from "../common/cpu/disasm6502"; -import { disassembleZ80 } from "../common/cpu/disasmz80"; -import { disassembleSM83 } from "../common/cpu/disasmSM83"; -import { disassembleF8 } from "../common/cpu/disasmF8"; -import { disassembleHuC6280 } from "../common/cpu/disasmHuC6280"; -import { CPU6809 } from "../common/cpu/6809"; +import { EmuCore } from "../common/emucore"; import { PLATFORMS } from "../common/emu"; -import * as emu from "../common/emu"; import { getRootBasePlatform } from "../common/util"; import { importPlatform } from "../platform/_index"; import { mockAudio, mockDOM, mockFetch, mockGlobals } from "./nodemock"; -export interface VideoOutput { - pixels: Uint32Array; - width: number; - height: number; - /** display rotation in degrees (RasterVideo's `rotate` option) */ - rotate?: number; - /** display aspect ratio, if not width/height */ - aspect?: number; -} - -export interface DebugSection { - category: string; - text: string; -} - -/** Disassemblers, keyed by the `arch` name used in the worker's platform params. */ -export const DISASSEMBLERS: { [arch: string]: (addr: number, read: (a: number) => number) => DisasmLine } = { - '6502': (a, r) => disassemble6502(a, r(a), r(a + 1), r(a + 2)), - 'huc6280': (a, r) => disassembleHuC6280(a, r(a), r(a + 1), r(a + 2)), - 'z80': (a, r) => disassembleZ80(a, r(a), r(a + 1), r(a + 2), r(a + 3)), - 'gbz80': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)), - 'sm83': (a, r) => disassembleSM83(a, r(a), r(a + 1), r(a + 2)), - 'f8': (a, r) => disassembleF8(a, r(a), r(a + 1), r(a + 2)), - '6809': (a, r) => Object.create(CPU6809()).disasm(r(a), r(a + 1), r(a + 2), r(a + 3), r(a + 4), a), -}; - -/** CPU class name -> arch, for the platforms that don't implement disassemble(). */ -const CPU_ARCH: { [cls: string]: string } = { - 'MOS6502': '6502', 'HuC6280': 'huc6280', - 'Z80': 'z80', 'ZilogZ80': 'z80', - 'SM83': 'sm83', 'F8CPU': 'f8', 'CPU6809': '6809', -}; - -/** Work out which disassembler a CPU object wants. */ -function archOf(cpu: any): string { - const arch = CPU_ARCH[cpu?.constructor?.name]; - if (arch) return arch; - // CPU6809 is a factory returning a plain object, so it has no class name to - // match; it is also the only CPU here that carries its own disassembler. - if (typeof cpu?.disasm === 'function') return '6809'; - return ''; -} - -// Frames to run before giving up on a `runUntil` predicate that never fires. -export const DEFAULT_MAX_FRAMES = 1000; - -// Upper bound on clocks in one instruction, so stepping can't spin forever. -const MAX_CYCLES_PER_INSN = 64; - -/** - * Headless stand-ins for RasterVideo/VectorVideo/AnimationTimer. Platform - * modules pick these up because Platform.start() reads them off the emu module. - */ -function installHeadlessVideo() { - // Platform.start() builds its video/timer by reading these classes off the - // emu module, so we swap in headless stand-ins for the duration of start() - // and put the real ones back afterwards. Leaving the stubs installed would - // leak into every other module sharing the (cached) emu import -- which is - // exactly what happens when mocha reuses a worker across test files. - let pixels: Uint32Array | null = null; - let params: { width: number, height: number, rotate?: number, aspect?: number } | null = null; - let frameRate = 60; - // the handler a platform registers on its canvas; it's how the IDE - // delivers keys, whether or not there's a Machine behind the platform - let keyHandler: ((key: number, code: number, flags: number) => void) | null = null; - const setKeyboardEvents = function (callback) { keyHandler ??= callback; }; - const RasterVideo: any = function (_el: any, width: number, height: number, options?: { rotate?: number, aspect?: number }) { - const buffer = new ArrayBuffer(width * height * 4); - const datau8 = new Uint8Array(buffer); - const datau32 = new Uint32Array(buffer); - // the first one is the screen; later ones are debug views (nes nametables) - if (!pixels) { - params = { width, height, rotate: options?.rotate, aspect: options?.aspect }; - pixels = datau32; - } - this.create = function () { this.width = width; this.height = height; }; - this.setKeyboardEvents = setKeyboardEvents; - this.getFrameData = function () { return datau32; }; - this.getImageData = function () { return { data: datau8, width, height }; }; - this.updateFrame = function () { }; - this.clearRect = function () { }; - this.setupMouseEvents = function () { }; - this.canvas = this; - this.getContext = function () { return this; }; - this.fillRect = function () { }; - this.fillStyle = ''; - this.putImageData = function () { }; - this.style = {}; - }; - const VectorVideo: any = function () { - this.create = function () { this.drawops = 0; }; - this.setKeyboardEvents = setKeyboardEvents; - this.clear = function () { }; - this.drawLine = function () { this.drawops++; }; - }; - const AnimationTimer: any = function (fps: number) { - if (fps > 0) frameRate = fps; - this.running = false; - this.start = function () { }; - this.stop = function () { }; - this.isRunning = function () { return this.running; }; - }; - const original = emu.setVideoClasses({ RasterVideo, VectorVideo, AnimationTimer }); - return { - get(): VideoOutput | null { - return pixels && params ? { pixels, ...params } : null; - }, - get frameRate() { return frameRate; }, - get keyHandler() { return keyHandler; }, - restore() { - emu.setVideoClasses(original); - } - }; -} - -export class EmuTarget { - frameCount = 0; - private video: ReturnType | null = null; - - constructor(readonly id: string, readonly platform: Platform) { - } - - /** - * The underlying Machine, if there is one. Platforms built on - * BaseMachinePlatform expose theirs, which is what lets us stop mid-frame - * through the TrapCondition the FrameBased interface already defines, - * instead of guessing at cycle counts. - */ - get machine(): Machine | null { - return (this.platform as any).machine || null; - } - - async start() { - // start() is where platforms construct their video and timer, so install - // the headless stand-ins just for that call, then restore the real classes. - const headless = installHeadlessVideo(); - this.video = headless; - try { - await this.platform.start(); - } finally { - headless.restore(); - } - } - reset() { this.platform.reset(); } - loadROM(data: Uint8Array, title = 'ROM') { this.platform.loadROM(title, data); } - - loadBIOS(data: Uint8Array, title = 'BIOS'): boolean { - if (!this.platform.loadBIOS) return false; - this.platform.loadBIOS(title, data); - return true; - } - - read(addr: number): number { - if (this.platform.readAddress) return this.platform.readAddress(addr); - const m = this.machine; - if (m) return m.readConst ? m.readConst(addr) : m.read(addr); - throw new Error(`platform '${this.id}' cannot read memory`); - } - - getCPUState(): CpuState | null { - try { return this.platform.getCPUState ? this.platform.getCPUState() : null; } - catch (e) { return null; } - } - - getPC(): number | null { - // Platforms may correct the PC (see debugPCDelta), so prefer getPC(). - try { if (this.platform.getPC) return this.platform.getPC(); } catch (e) { } - const s = this.getCPUState(); - return s ? s.PC : null; - } - - disassemble(addr: number): DisasmLine | null { - const read = (a: number) => this.read(a); - try { - if (this.platform.disassemble) return this.platform.disassemble(addr, read); - // a few platforms leave it out; fall back to the machine's CPU - const disasm = DISASSEMBLERS[archOf(this.machine?.cpu)]; - return disasm ? disasm(addr, read) : null; - } catch (e) { return null; } - } - - setKeyInput(key: number, code: number, flags: number) { - const handler = this.video?.keyHandler; - if (handler) return handler(key, code, flags); - const target: any = this.machine || this.platform; - if (typeof target.setKeyInput !== 'function') { - throw new Error(`platform '${this.id}' does not accept key input`); - } - target.setKeyInput(key, code, flags); - } - - connectProbe(probe: ProbeAll | null): boolean { - const m = this.machine; - if (!m || !hasProbe(m)) return false; - m.connectProbe(probe); - return true; - } - - getVideo(): VideoOutput | null { return this.video ? this.video.get() : null; } - /** Frames per second the platform's timer asked for (60 if unknown). */ - get frameRate(): number { return this.video ? this.video.frameRate : 60; } - saveState(): EmuState | null { return this.platform.saveState ? this.platform.saveState() : null; } - - getDebugInfo(): DebugSection[] { - const state = this.saveState(); - const p: any = this.platform; - if (!state || !isDebuggable(p) || !p.getDebugCategories) return []; - const sections: DebugSection[] = []; - for (const category of p.getDebugCategories() || []) { - try { - const text = p.getDebugInfo(category, state); - if (text) sections.push({ category, text }); - } catch (e) { /* a category that doesn't apply to this state */ } - } - return sections; - } - - //// execution control, driven by what the target turns out to support - - /** True if execution can be stopped mid-frame at an exact instruction. */ - get supportsTrap(): boolean { return this.machine != null; } - - /** True if single instructions can be stepped. */ - get supportsStep(): boolean { return this.supportsTrap; } - - /** - * A function that advances the CPU by the smallest amount the machine - * supports -- one clock for ClockBased CPUs, one instruction for - * InstructionBased ones -- without disturbing the frame loop. Null if the - * machine only knows how to run whole frames. - */ - private clockStepper(): (() => void) | null { - const m = this.machine as any; - if (!m) return null; - if (typeof m.advanceCPU === 'function') return () => m.advanceCPU(); // BasicHeadlessMachine - if (typeof m.advanceFrameClock === 'function') return () => m.advanceFrameClock(null, 1); // WASM - return null; - } - - /** - * Finish any partly-executed instruction, so getPC() names a real - * instruction. Frames can end mid-instruction on clock-based CPUs. - */ - settle(): void { - const clock = this.clockStepper(); - if (!clock) return; - for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock(); - } - - advanceFrame(trap?: TrapCondition | null): void { - const m = this.machine; - // With a trap we drive the Machine directly -- Platform.advance() only - // honors traps registered as breakpoints, and installing/removing those - // rewinds BaseDebugPlatform's saved state. - if (trap && m) { - m.advanceFrame(trap); - } else { - const p = this.platform as any; - if (p.nextFrame) p.nextFrame(); - else if (p.advance) p.advance(false); - } - this.frameCount++; - } - - isStable(): boolean { - return this.machine ? this.machine.cpu.isStable() : true; - } - - /** Execute exactly one instruction. Returns false if the target can't step. */ - stepInsn(): boolean { - if (!this.machine) return false; - const clock = this.clockStepper(); - if (clock) { - this.settle(); // don't count an in-flight instruction as this step - clock(); - for (let i = 0; i < MAX_CYCLES_PER_INSN && !this.isStable(); i++) clock(); - return true; - } - // No clock-level entry point: stop at the next instruction boundary via - // the frame trap. This restarts the frame, so it is the last resort. - let boundaries = 0; - for (let frames = 0; frames < 2 && boundaries < 2; frames++) { - this.advanceFrame(() => this.isStable() && ++boundaries >= 2); - } - return true; - } - - /** - * Run until `pred` is true, up to `maxFrames` frames. Exact to the - * instruction on targets with a Machine; frame-granular otherwise. - */ - runUntil(pred: () => boolean, maxFrames = DEFAULT_MAX_FRAMES): boolean { - const start = this.frameCount; - while (this.frameCount - start < maxFrames) { - if (this.supportsTrap) { - let hit = false; - this.advanceFrame(() => (hit = this.isStable() && pred())); - if (hit) return true; - } else { - this.advanceFrame(); - if (pred()) return true; - } - } - return false; - } - - /** Run until the PC reaches one of `addrs`. */ - runToPC(addrs: Set, maxFrames = DEFAULT_MAX_FRAMES): boolean { - return this.runUntil(() => addrs.has(this.getPC()), maxFrames); - } -} +export { EmuCore as EmuTarget, DISASSEMBLERS, DEFAULT_MAX_FRAMES } from "../common/emucore"; +export type { VideoOutput, DebugSection } from "../common/emucore"; +type EmuTarget = EmuCore; /** Load a platform module by ID (e.g. "nes", "c64.wasm", "atari8-5200"). */ export async function loadPlatform(platformId: string): Promise { @@ -349,7 +22,7 @@ export async function loadPlatform(platformId: string): Promise { if (!PlatformClass) { throw new Error(`Platform '${platformId}' not found. Available: ${Object.keys(PLATFORMS).sort().join(', ')}`); } - return new EmuTarget(platformId, new PlatformClass(null)); + return new EmuCore(platformId, new PlatformClass(null)); } let mocksInstalled = false; diff --git a/src/tools/runscript.ts b/src/tools/runscript.ts index 85583431..5dd2b816 100644 --- a/src/tools/runscript.ts +++ b/src/tools/runscript.ts @@ -9,6 +9,8 @@ import { KeyFlags } from '../common/emu'; import { ProbeFlags, ProbeRecorder } from '../common/probe'; import { hex } from '../common/util'; +import type { SymbolMap } from '../common/baseplatform'; +import { lookupSymbol } from '../common/symbols/symbolfile'; import { hexdump, write } from './cliformat'; import { DEFAULT_MAX_FRAMES, EmuTarget } from './emutarget'; @@ -105,13 +107,13 @@ export function formatRegs(state: any): string { const PROBE_BUFFER_SIZE = 0x400000; export class RunScript { - symbols: { [name: string]: number } = {}; + symbols: SymbolMap = {}; private addr2symbol: { [addr: number]: string } = {}; private probe: ProbeRecorder | null = null; constructor(readonly target: EmuTarget, private out = write) { } - addSymbols(symbols: { [name: string]: number }) { + addSymbols(symbols: SymbolMap) { Object.assign(this.symbols, symbols); for (const [name, addr] of Object.entries(this.symbols)) this.addr2symbol[addr] = name; } @@ -151,7 +153,7 @@ export class RunScript { private addr(tok: string): number { try { return parseNum(tok); } catch (e) { - const v = this.symbols[tok] ?? this.symbols[tok.replace(/^\./, '')]; + const v = lookupSymbol(this.symbols, tok); if (v == null) throw new Error(`unknown address or symbol '${tok}'`); return v; } @@ -368,15 +370,3 @@ const COMMANDS: { [name: string]: Command } = { 'reset': RunScript.prototype.cmdReset, 'echo': RunScript.prototype.cmdEcho, }; - -/** Parse a cc65/ca65 or VICE label file into a symbol map. */ -export function parseSymbolFile(text: string): { [name: string]: number } { - const symbols: { [name: string]: number } = {}; - for (const line of text.split(/\r?\n/)) { - const m1 = line.match(/^\s*([A-Za-z_][\w]*)\s*=\s*\$?([0-9A-Fa-f]+)\s*;/); // ca65/cc65 list - const m2 = line.match(/^\s*(?:al|add_label)\s+([0-9A-Fa-f]+)\s+\.?([A-Za-z_][\w]*)/); // VICE - if (m1) symbols[m1[1]] = parseInt(m1[2], 16); - else if (m2) symbols[m2[2]] = parseInt(m2[1], 16); - } - return symbols; -} diff --git a/test/cli/testcli.js b/test/cli/testcli.js index 1064e7d6..8d67b0b7 100644 --- a/test/cli/testcli.js +++ b/test/cli/testcli.js @@ -85,6 +85,10 @@ describe('8bws CLI', function () { assert.ok(/break \$[0-9A-F]+: HIT/.test(out), out); assert.ok(/_main:/.test(out), out); }); + it('should find a C symbol without its underscore', function () { + var out = cli('run', '--platform', 'gb', 'presets/gb/hello.c', '-e', 'break main 600; pc 1'); + assert.ok(/break \$[0-9A-F]+: HIT/.test(out), out); + }); it('should write a screenshot', function () { var png = path.join(os.tmpdir(), '8bws-test-shot.png'); if (fs.existsSync(png)) fs.unlinkSync(png); diff --git a/test/unit/testbreakpoints.ts b/test/unit/testbreakpoints.ts index cbd99616..1c6a0c8f 100644 --- a/test/unit/testbreakpoints.ts +++ b/test/unit/testbreakpoints.ts @@ -1,5 +1,8 @@ import { describe } from "mocha"; -import { compileCondition, parseTarget } from "../../src/ide/breakcond"; +import { compileCondition, parseTarget } from "../../src/common/breakcond"; +import { Breakpoint, BreakpointContext, canUseBreakpoints, resolveBreakpoint } from "../../src/common/breakpoints"; +import { lookupSymbol, parseSymbolFile } from "../../src/common/symbols/symbolfile"; +import { SourceFile } from "../../src/common/workertypes"; import assert from "assert"; const ctx = { @@ -142,3 +145,59 @@ describe('Breakpoints', () => { assert.ok(parseTarget('12ab', sym).error); // neither number nor symbol }); }); + +describe('Breakpoint resolution (shared by IDE, CLI, debug adapter)', () => { + + const symbols = { _main: 0x1234, mainloop: 0x800, vblank: 0x900 }; + const sourcefile = new SourceFile([{ line: 10, offset: 0x1234 } as any], ''); + const ctx: BreakpointContext = { + symbols, + getListingForFile: (path) => path == 'game.c' ? { lines: [], sourcefile } as any : undefined, + platform: { + getCPUState: () => ({ PC: 0x1234, A: 7 }), + readAddress: (a: number) => (a == 0x10 ? 0x2a : 0), + } as any, + }; + const bp = (patch: Partial): Breakpoint => ({ id: 1, type: 'address', enabled: true, ...patch }); + + it('should resolve a symbol, with or without the C underscore', () => { + assert.equal(resolveBreakpoint(bp({ target: 'mainloop' }), ctx).pc, 0x800); + assert.equal(resolveBreakpoint(bp({ target: '_main' }), ctx).pc, 0x1234); + assert.equal(resolveBreakpoint(bp({ target: 'main' }), ctx).pc, 0x1234); + assert.ok(resolveBreakpoint(bp({ target: 'nosuch' }), ctx).error); + }); + it('should resolve a source line through the listing', () => { + assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 10 }), ctx).pc, 0x1234); + assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 11 }), ctx).error, 'line has no code'); + assert.equal(resolveBreakpoint(bp({ type: 'source', file: 'other.c', line: 10 }), ctx).error, 'no debug info (build first?)'); + }); + it('should compile a condition against the host platform', () => { + let r = resolveBreakpoint(bp({ target: '$800', condition: 'A == 7 && [$10] == 42' }), ctx); + assert.equal(r.pc, 0x800); + assert.equal(r.condFn({ A: 7 }), true); + assert.equal(r.condFn({ A: 6 }), false); + }); + it('should work without a platform or listings', () => { + assert.equal(resolveBreakpoint(bp({ target: '$c000' }), {}).pc, 0xc000); + assert.ok(resolveBreakpoint(bp({ type: 'source', file: 'game.c', line: 10 }), {}).error); + }); + it('should report whether a platform can stop at breakpoints', () => { + assert.equal(canUseBreakpoints(null), false); + assert.equal(canUseBreakpoints({ runEval: () => { } } as any), true); + }); +}); + +describe('Symbol files', () => { + it('should parse ca65 and VICE label files', () => { + let syms = parseSymbolFile('main = $1234 ;\nal 00C000 .vblank\nadd_label 0800 loop\n'); + assert.deepEqual(syms, { main: 0x1234, vblank: 0xc000, loop: 0x800 }); + }); + it('should look up names as a user types them', () => { + let syms = { _main: 1, loop: 2 }; + assert.equal(lookupSymbol(syms, '_main'), 1); + assert.equal(lookupSymbol(syms, 'main'), 1); + assert.equal(lookupSymbol(syms, '.loop'), 2); + assert.equal(lookupSymbol(syms, 'nope'), undefined); + assert.equal(lookupSymbol(null, 'main'), undefined); + }); +});