From 60e5577fc38845802cb183ede0220feb8d08d46d Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 3 Oct 2026 20:34:42 +0200 Subject: [PATCH] verilog: record to VCD --- CHANGELOG.md | 1 + extension/CHANGELOG.md | 4 ++ extension/README.md | 2 + extension/package.json | 18 +++++ extension/src/emuworker.ts | 61 +++++++++++++++- extension/src/extension.ts | 50 ++++++++++++- extension/test/emuworker.test.ts | 87 +++++++++++++++++++++- src/common/baseplatform.ts | 5 ++ src/common/emucore.ts | 58 +++++++++++++++ src/common/vcd.ts | 120 +++++++++++++++++++++++++++++++ src/common/waveform.ts | 2 + src/platform/verilog.ts | 8 ++- src/tools/8bws.ts | 9 ++- src/tools/runscript.ts | 35 +++++++++ src/tools/vcdfile.ts | 39 ++++++++++ test/cli/testcli.js | 35 +++++++++ test/unit/testvcd.ts | 84 ++++++++++++++++++++++ 17 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 src/common/vcd.ts create mode 100644 src/tools/vcdfile.ts create mode 100644 test/unit/testvcd.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 080b8f39..647ad4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md). - Emulators now run on a timeline: step back, seek, reverse run, `rbreak`, and deterministic replay with a replay-verification test that found determinism bugs in several platforms. - Step Over for C and assembly sources, a Breakpoints pane, and more breakpoint conditions. - `8bws run` script `paddle X Y [BUTTONS]` moves the paddle on platforms and Verilog designs that have one. +- `8bws run` script `vcd FILE [MAXMB]` / `vcd off` records a Verilog design's signal changes to a VCD file as it runs, streamed so the recording's length isn't limited by memory (a `.gz` name compresses it; about 3 MB per frame of a design with video, 4x less gzipped), stopping at 1 GB unless told otherwise. - Debug adapter: Verilog designs get a Signals scope (nested by module), and the `8bws` run script a `signals` command. - Verilator warnings (width mismatches, etc.) no longer fail the build and show as warnings; a Verilator error no longer also reports "tool called exit(1)". - Call Stack view renamed Call Graph, with clocks and more detail per line. diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index d26d7a4d..be863fb3 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -12,6 +12,10 @@ under a stack frame named for the frame and clock cycle. - A Machine view in a new 8bitworkshop panel shows the platform's debug info, live while the program runs, for platforms that have it. +- **Record Signals to VCD...** writes a Verilog design's signal changes to a + `.vcd` file as it runs, without holding them in memory, up to + 1 GB. `8bws run` has a + matching `vcd FILE` / `vcd off` script command. - Infer symbol sizes from build output, instead of one byte per variable. - Removed Hex Editor extension integration, we'll come up with a better one. - Removed the assetUrl setting. diff --git a/extension/README.md b/extension/README.md index 42ede413..9ade1be2 100644 --- a/extension/README.md +++ b/extension/README.md @@ -98,6 +98,8 @@ also appear in the editor title bar, the Explorer, or **Project Options**. | **Follow Active Editor** | Run whichever program is in the editor | | **Reset / Pause / Resume / Stop / Mute / Unmute Emulator** | Control the emulator panel (shown while it is open) | | **Show Machine View** | Open the Machine view in the 8bitworkshop panel: the platform's debug info (registers, video chip state), updated while the program runs | +| **Record Signals to VCD...** | Verilog: write the design's signal changes for a few frames to a `.vcd` file, for a waveform viewer. About 3 MB per frame for a design with video; name the file `.vcd.gz` to compress it about 4x. A recording stops at 1 GB | +| **Stop Recording VCD** | End a recording before its frames are up | ## Books diff --git a/extension/package.json b/extension/package.json index 01cc00fc..0a212099 100644 --- a/extension/package.json +++ b/extension/package.json @@ -196,6 +196,16 @@ "command": "8bitworkshop.showMachine", "title": "Show Machine View", "category": "8bitworkshop" + }, + { + "command": "8bitworkshop.recordVcd", + "title": "Record Signals to VCD...", + "category": "8bitworkshop" + }, + { + "command": "8bitworkshop.stopVcd", + "title": "Stop Recording VCD", + "category": "8bitworkshop" } ], "viewsContainers": { @@ -558,6 +568,14 @@ { "command": "8bitworkshop.showMachine", "when": "8bitworkshop.hasDebugInfo" + }, + { + "command": "8bitworkshop.recordVcd", + "when": "8bitworkshop.canRecordVcd && !8bitworkshop.recordingVcd" + }, + { + "command": "8bitworkshop.stopVcd", + "when": "8bitworkshop.recordingVcd" } ] }, diff --git a/extension/src/emuworker.ts b/extension/src/emuworker.ts index fff9e35a..e308bd53 100644 --- a/extension/src/emuworker.ts +++ b/extension/src/emuworker.ts @@ -17,6 +17,7 @@ import type { StopEvent } from '../../src/common/debugcontroller'; import type { DebugSection } from '../../src/common/emucore'; import { BuildInfo, DebugService, TimelineInfo } from '../../src/tools/debugservice'; import { RunScript } from '../../src/tools/runscript'; +import { openVcdFile, VcdFile } from '../../src/tools/vcdfile'; import { buildDebugContext } from '../../src/common/debugcontroller'; import { encode as encodePng } from 'fast-png'; @@ -62,6 +63,19 @@ export interface EmuStatus { timeline?: TimelineInfo; /** the platform has debug info text for the Machine view */ debugInfo?: boolean; + /** the program has signals that can be recorded as a VCD (startVcd) */ + vcd?: boolean; +} + +/** A VCD recording started or ended (the 'vcd' event). */ +export interface VcdEvent { + recording: boolean; + file: string; + /** when it ended: the clocks it holds and the bytes written */ + clocks?: number; + bytes?: number; + /** it stopped because the file reached its size limit */ + full?: boolean; } /** What a debug view gets (see DebugViews in views.ts). */ @@ -94,6 +108,8 @@ const DEBUG_METHODS = new Set([ 'seekFrame', 'location', 'timeline', 'registers', 'readMemory', 'writeMemory', 'disassemble', 'evaluate', 'debugTree', 'signalTree', 'symbols', 'callStack', ]); +// the ones that replay frames the recording already has +const REWIND_METHODS = new Set(['stepBack', 'reverseContinue', 'seekFrame']); // the ones that start the machine running toward a goal const FORWARD_METHODS = new Set(['continue', 'step']); @@ -121,6 +137,8 @@ let muted = false; /** the views the host has showing: only these get data */ let views = new Set(); let lastViewPush = 0; +/** the VCD file being written, and the frame to stop at (if it has a length) */ +let vcdFile: { file: VcdFile, path: string, end: number | null } | null = null; /** * The headless stand-in for the platform's Web Audio sink: SampleAudio hands @@ -162,16 +180,41 @@ const rpc: Rpc = new Rpc(parentPort, { }, async loadROM(rom: any, files?: { [path: string]: FileData }, opts: LoadOptions = {}) { if (!target) throw new Error('emulator not started'); + stopVcd(); target.setFileData(files || {}); await target.loadROM(rom); if (!opts.paused) resume(); rpc.emit('audioReset', null); return status(); }, + /** + * Record the design's signals to `file` as a VCD, for `frames` frames (or + * until stopVcd or the file reaches `maxBytes`, by default 1 GB). It slows the simulation and + * writes ~3MB a frame for a VGA design (a quarter of that if `file` ends in + * .gz), so the host asks for a length. + */ + startVcd(file: string, frames?: number, maxBytes?: number) { + if (!target) throw new Error('emulator not started'); + if (!target.supportsVcd) throw new Error(`'${target.id}' has no signals to record`); + stopVcd(); + const rec = { file: openVcdFile(file, maxBytes), path: file, end: frames ? target.frameCount + frames : null }; + try { + target.startVcd(chunk => rec.file.write(chunk), () => rec.file.full); + } catch (e) { + rec.file.close(); + throw e; + } + vcdFile = rec; + rpc.emit('vcd', { recording: true, file } as VcdEvent); + }, + stopVcd() { + return stopVcd(); + }, /** Call a DebugService method; stops come back as 'stopped' events. */ debug(method: string, ...args: any[]) { if (!service) throw new Error('emulator not started'); if (!DEBUG_METHODS.has(method)) throw new Error(`no debug method '${method}'`); + if (REWIND_METHODS.has(method)) stopVcd(); const result = (service as any)[method](...args); if (FORWARD_METHODS.has(method)) startRunning(); return result; @@ -191,6 +234,7 @@ const rpc: Rpc = new Rpc(parentPort, { /** Show a recorded frame, stopped there. */ seekFrame(frame: number) { if (!service) return null; + stopVcd(); service.seekFrame(frame); return status(); }, @@ -259,7 +303,7 @@ function status(): EmuStatus | null { const audio = target.getAudioParams(); return { state: running ? 'running' : 'paused', platform: target.id, frame: target.frameCount, - controls, paddles: target.acceptsPaddles(), debugInfo: target.hasDebugInfo, audio: audio ? { sampleRate: audio.sampleRate } : undefined, + controls, paddles: target.acceptsPaddles(), debugInfo: target.hasDebugInfo, vcd: target.supportsVcd, audio: audio ? { sampleRate: audio.sampleRate } : undefined, timeline: service?.timeline() ?? undefined, }; } @@ -311,9 +355,23 @@ function stopTimer() { timer = null; } +/** Finish the VCD file, if one is being written. */ +function stopVcd(): VcdEvent | null { + const f = vcdFile; + if (!f) return null; + vcdFile = null; + // the writer's last chunk goes out before the file closes + const clocks = target ? target.stopVcd() : 0; + f.file.close(); + const ev: VcdEvent = { recording: false, file: f.path, clocks, bytes: f.file.bytes, full: f.file.full }; + rpc.emit('vcd', ev); + return ev; +} + function stop() { running = false; stopTimer(); + stopVcd(); rpc.emit('status', status()); target = null; service = null; @@ -351,6 +409,7 @@ function tick() { return; } if (!running) return; // stopped during the frame + if (vcdFile && ((vcdFile.end != null && target.frameCount >= vcdFile.end) || !target.vcdRunning)) stopVcd(); if (now - nextTime > interval * MAX_CATCHUP_FRAMES) nextTime = now; // too far behind if (frames) sendFrame(); timer = setTimeout(tick, Math.max(0, nextTime - performance.now())); diff --git a/extension/src/extension.ts b/extension/src/extension.ts index a84ef3ec..3c6123a3 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { BuildOutcome } from './buildcore'; import type { BuildArgs } from './buildworker'; -import type { AudioChunk, EmuStatus, ScriptResult } from './emuworker'; +import type { AudioChunk, EmuStatus, ScriptResult, VcdEvent } from './emuworker'; import { WorkerHandle, WorkerDisposedError } from './engine'; import { EmulatorPanel, VIEW_TYPE } from './emulatorpanel'; import { DebugViews } from './views'; @@ -121,6 +121,8 @@ export function activate(ctx: vscode.ExtensionContext) { command('downloadToolchains', () => prepareToolchains()); command('mute', () => setMuted(true)); command('unmute', () => setMuted(false)); + command('recordVcd', () => recordVcd()); + command('stopVcd', () => emu?.started && emu.call('stopVcd')); command('showMachine', () => vscode.commands.executeCommand('8bitworkshop.machine.focus')); ctx.subscriptions.push(...views.register()); muted = ctx.globalState.get('muted', false); @@ -313,10 +315,12 @@ async function getEmu(platform: string): Promise { emu.on('audioReset', () => panel?.resetAudio()); emu.on('stopped', e => debugging?.backend.handleStop(e)); emu.on('view', ev => views.show(ev)); + emu.on('vcd', (ev: VcdEvent) => vcdEvent(ev)); emu.on('status', (s: EmuStatus | null) => { emuStatus = s; panel?.showStatus(s); vscode.commands.executeCommand('setContext', '8bitworkshop.hasDebugInfo', !!s?.debugInfo); + vscode.commands.executeCommand('setContext', '8bitworkshop.canRecordVcd', !!s?.vcd); vscode.commands.executeCommand('setContext', '8bitworkshop.emuRunning', s?.state === 'running'); if (s?.state === 'halted') output.appendLine(`Emulator halted at frame ${s.frame}: ${s.message}`); }); @@ -628,6 +632,7 @@ async function startEmulator(target: Target, build: BuildOutcome, opts: { paused // a plain Run replaces what the debugger was looking at if (debugging && !opts.paused) vscode.debug.stopDebugging(debugging.session); // each run gets a fresh worker: platforms keep global state (see emuworker) + if (emu?.started) await emu.call('stopVcd').catch(() => { }); emu?.dispose(); emu = undefined; emuStatus = null; @@ -652,6 +657,8 @@ async function startEmulator(target: Target, build: BuildOutcome, opts: { paused emu = undefined; views.clear(); vscode.commands.executeCommand('setContext', '8bitworkshop.hasDebugInfo', false); + vscode.commands.executeCommand('setContext', '8bitworkshop.canRecordVcd', false); + vscode.commands.executeCommand('setContext', '8bitworkshop.recordingVcd', false); vscode.commands.executeCommand('setContext', '8bitworkshop.emuRunning', false); vscode.commands.executeCommand('setContext', '8bitworkshop.emuOpen', false); }, @@ -668,6 +675,7 @@ async function startEmulator(target: Target, build: BuildOutcome, opts: { paused worker.call('setMuted', muted); worker.call('setViews', views.subscriptions()); vscode.commands.executeCommand('setContext', '8bitworkshop.hasDebugInfo', !!emuStatus?.debugInfo); + vscode.commands.executeCommand('setContext', '8bitworkshop.canRecordVcd', !!emuStatus?.vcd); panel.showStatus(emuStatus); running = target; runningBuild = build; @@ -703,6 +711,46 @@ async function reloadEmulator(target: Target, reason: BuildReason, result: Build } } +/** Verilog designs: write the signals' changes for a few frames to a VCD file. */ +async function recordVcd() { + if (!emu?.started || !emuStatus?.vcd) { + vscode.window.showInformationMessage('Run a Verilog design first; only designs with signals can be recorded.'); + return; + } + var dir = running ? vscode.Uri.joinPath(running.main, '..') : vscode.workspace.workspaceFolders?.[0]?.uri; + var base = running ? path.basename(running.main.path).replace(/\.[^.]*$/, '') : 'trace'; + var file = await vscode.window.showSaveDialog({ + defaultUri: dir && vscode.Uri.joinPath(dir, base + '.vcd'), + filters: { 'Value change dump': ['vcd', 'gz'] }, + title: 'Record signals to a VCD file', + }); + if (!file) return; + // a VGA design writes about 3 MB a frame, so it stops by itself + var answer = await vscode.window.showInputBox({ + title: 'Frames to record', + prompt: 'A design with video writes about 3 MB per frame (a quarter of that if the file name ends in .gz), and runs more slowly while recording.', + value: '2', + validateInput: v => /^[1-9]\d*$/.test(v.trim()) ? undefined : 'Enter a whole number of frames', + }); + if (!answer) return; + try { + await emu.call('startVcd', file.fsPath, Number(answer.trim())); + } catch (e) { + vscode.window.showErrorMessage(`Could not record: ${e && e.message || e}`); + } +} + +function vcdEvent(ev: VcdEvent) { + vscode.commands.executeCommand('setContext', '8bitworkshop.recordingVcd', ev.recording); + if (ev.recording) return; + var limit = ev.full ? ', stopped at the 1 GB limit' : ''; + output.appendLine(`Wrote ${ev.clocks} clocks to ${ev.file} (${mb(ev.bytes || 0)}${limit})`); + vscode.window.showInformationMessage(`Recorded ${ev.clocks} clocks (${mb(ev.bytes || 0)}) to ${path.basename(ev.file)}${limit}.`, 'Show in Folder') + .then(choice => { + if (choice) vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(ev.file)); + }); +} + /** Turn sound on or off everywhere (the webview gain and the worker's push). */ function setMuted(m: boolean) { muted = m; diff --git a/extension/test/emuworker.test.ts b/extension/test/emuworker.test.ts index 2d486600..7dcff60a 100644 --- a/extension/test/emuworker.test.ts +++ b/extension/test/emuworker.test.ts @@ -1,12 +1,13 @@ import * as assert from 'assert'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { Worker } from 'worker_threads'; import { Rpc } from '../src/rpc'; import { findRootDir } from '../src/projectinfo'; import { Builder, ProjectFileProvider } from '../src/buildcore'; -import type { AudioChunk, EmuStatus, FrameEvent, ScriptResult, ViewEvent } from '../src/emuworker'; +import type { AudioChunk, EmuStatus, FrameEvent, ScriptResult, VcdEvent, ViewEvent } from '../src/emuworker'; import { WorkerDebugBackend } from '../src/debugbackend'; import { EmuDebugSession } from '../../src/tools/dapsession'; import { DapClient } from '../../test/unit/dapclient'; @@ -27,6 +28,7 @@ describe('extension emuworker', function () { var statuses: EmuStatus[]; var audios: AudioChunk[]; var viewEvents: ViewEvent[]; + var vcdEvents: VcdEvent[]; beforeEach(function () { worker = new Worker(path.join(__dirname, '..', 'emuworker.js'), { workerData: { rootDir: ROOT } }); @@ -35,6 +37,8 @@ describe('extension emuworker', function () { statuses = []; audios = []; viewEvents = []; + vcdEvents = []; + rpc.on('vcd', v => vcdEvents.push(v)); rpc.on('view', v => viewEvents.push(v)); rpc.on('frame', f => frames.push(f)); rpc.on('status', s => statuses.push(s)); @@ -246,6 +250,87 @@ describe('extension emuworker', function () { }); }); + describe('verilog VCD recording', function () { + const MAIN = [ + 'module top(clk, reset, hsync, vsync, rgb);', + ' input clk, reset;', + ' output hsync, vsync;', + ' output [3:0] rgb;', + ' reg [7:0] n;', + ' always @(posedge clk) n <= reset ? 0 : n + 1;', + ' assign hsync = n[7];', + ' assign vsync = 0;', + ' assign rgb = n[3:0];', + 'endmodule', + '', + ].join('\n'); + var built: Awaited>; + var dir: string; + + before(async function () { + var read = async (rel: string) => null; + built = await new Builder(ROOT).build({ + platform: 'verilog', mainPath: 'main.v', mainText: MAIN, + files: new ProjectFileProvider(read, ROOT, 'verilog'), + }); + assert.deepEqual(built.diagnostics, []); + dir = fs.mkdtempSync(path.join(os.tmpdir(), '8bws-vcd-')); + }); + + async function waitForVcd(recording: boolean) { + for (var i = 0; i < 400 && !vcdEvents.some(e => e.recording === recording); i++) await new Promise(r => setTimeout(r, 20)); + var ev = vcdEvents.find(e => e.recording === recording); + assert.ok(ev, 'no vcd event ' + JSON.stringify(vcdEvents)); + return ev!; + } + + it('records a number of frames to a file and stops by itself', async function () { + var s = await rpc.call('start', 'verilog', built.output, built.files); + assert.ok(s.vcd, 'verilog design has signals to record'); + var file = path.join(dir, 'a.vcd'); + await rpc.call('startVcd', file, 2); + var done = await waitForVcd(false); + assert.equal(done.file, file); + assert.ok(done.clocks! > 0 && done.bytes! > 0); + var text = fs.readFileSync(file, 'utf-8'); + assert.equal(text.length, done.bytes); + assert.ok(text.startsWith('$version')); + assert.ok(/\$var wire 8 \S+ n \[7:0\] \$end/.test(text), text.slice(0, 400)); + assert.ok(text.endsWith('#' + done.clocks + '\n')); + // the counter changes every clock + assert.ok(text.split('\n').filter(l => /^b\d+ /.test(l)).length > 1000); + }); + + it('stops when asked, and a hidden-length recording keeps going until then', async function () { + await rpc.call('start', 'verilog', built.output, built.files); + var file = path.join(dir, 'b.vcd'); + await rpc.call('startVcd', file); + await new Promise(r => setTimeout(r, 300)); + assert.ok(!vcdEvents.some(e => !e.recording), 'no length, so it runs on'); + var ev = await rpc.call('stopVcd'); + assert.equal(ev.file, file); + assert.ok(ev.clocks! > 0); + assert.equal(await rpc.call('stopVcd'), null); + }); + + it('ends itself at the size limit', async function () { + await rpc.call('start', 'verilog', built.output, built.files); + var file = path.join(dir, 'full.vcd'); + await rpc.call('startVcd', file, 0, 100000); + var done = await waitForVcd(false); + assert.ok(done.full, JSON.stringify(done)); + assert.ok(done.bytes! >= 100000 && done.bytes! < 300000, 'bytes ' + done.bytes); + assert.equal(fs.statSync(file).size, done.bytes); + assert.equal(await rpc.call('stopVcd'), null, 'already finished'); + }); + + it('refuses platforms with no signals', async function () { + var s = await rpc.call('start', 'nes', rom('nes/shoot2.c.rom')); + assert.ok(!s.vcd); + await assert.rejects(rpc.call('startVcd', path.join(dir, 'c.vcd')), /no signals/); + }); + }); + describe('debugging', function () { var built: Awaited>; var stops: any[]; diff --git a/src/common/baseplatform.ts b/src/common/baseplatform.ts index 7620ea92..08bc0737 100644 --- a/src/common/baseplatform.ts +++ b/src/common/baseplatform.ts @@ -158,6 +158,11 @@ export interface Platform { setPaddles?(x: number, y: number, buttons: boolean[]): void; /** HDL platforms: clock cycles since power-up */ getClockCount?(): number | undefined; + /** + * HDL platforms: call `sink` with the design's state before every clock + * (null stops). Costs the fast frame path, so only while recording. + */ + setTraceSink?(sink: ((state: {[name: string]: any}) => void) | null): void; startProbing?(): ProbeRecorder; stopProbing?(): void; diff --git a/src/common/emucore.ts b/src/common/emucore.ts index 0305dca4..a5235506 100644 --- a/src/common/emucore.ts +++ b/src/common/emucore.ts @@ -27,6 +27,8 @@ import { } from "./baseplatform"; import { ProbeAll, SampledAudioParams, TrapCondition } from "./devices"; import { History } from "./history"; +import { VCDWriter } from "./vcd"; +import type { WaveformMeta } from "./waveform"; import { createCore, isRewindable, PlatformFrameInput } from "./platformcore"; import { compareTimestamps, Timestamp, timestamp } from "./timeline"; import { FileData } from "./workertypes"; @@ -177,6 +179,10 @@ export class EmuCore { private recording: History | null = null; // the machine's state was changed from outside the recording private recordingStale = true; + /** the VCD recording in progress (see startVcd) */ + private vcd: VCDWriter | null = null; + /** the clocks in a recording that stopped itself (full), until stopVcd collects them */ + private vcdFinished = 0; private input: PlatformFrameInput | null = null; private probe: ProbeAll | null = null; // frames run on a platform without a timeline @@ -284,6 +290,8 @@ export class EmuCore { * to see their errors. */ async loadROM(data: Uint8Array | object, title = 'ROM') { + // a new design has other signals + this.stopVcd(); await this.platform.loadROM(title, data); this.recordingStale = true; } @@ -451,6 +459,56 @@ export class EmuCore { return typeof p.getSerialOutput === 'function' ? p.getSerialOutput() : null; } + /** True if the platform can record its signals as a VCD (HDL platforms, once loaded). */ + get supportsVcd(): boolean { + const p: any = this.platform; + return typeof p.setTraceSink === 'function' && typeof p.getSignalMetadata === 'function' + && (p.getSignalMetadata() || []).length > 0; + } + + /** + * Record every clock's signal changes as a VCD, handing the text to `write` + * in chunks as it goes (nothing is kept, so a recording can be as long as + * the disk allows). Recording slows the simulation. Stops any recording + * already going; returns false if the platform has no signals to record. + * If `isFull` is given it is asked before every clock, and the recording + * ends itself when it says so (see vcdRunning). + */ + startVcd(write: (chunk: string) => void, isFull?: () => boolean): boolean { + this.stopVcd(); + if (!this.supportsVcd) return false; + const p: any = this.platform; + const signals = (p.getSignalMetadata() as WaveformMeta[]).filter(s => s.name).map(s => ({ name: s.name!, len: s.len })); + const writer = new VCDWriter(signals, write); + this.vcdFinished = 0; + p.setTraceSink((state: any) => { + // between clocks, so the writer isn't in the middle of a write + if (isFull && isFull()) this.vcdFinished = this.finishVcd(); + else writer.sample(state); + }); + this.vcd = writer; + return true; + } + + /** True while a VCD recording is going (it ends itself if its `isFull` says so). */ + get vcdRunning(): boolean { return this.vcd != null; } + + /** Finish the recording, if there is one. Returns the number of clocks it holds. */ + stopVcd(): number { + if (this.vcd) return this.finishVcd(); + const clocks = this.vcdFinished; + this.vcdFinished = 0; + return clocks; + } + + private finishVcd(): number { + const writer = this.vcd!; + this.vcd = null; + (this.platform as any).setTraceSink(null); + writer.finish(); + return writer.samples; + } + /** True if the platform has debug info text to show (see getDebugInfo). */ get hasDebugInfo(): boolean { const p: any = this.platform; diff --git a/src/common/vcd.ts b/src/common/vcd.ts new file mode 100644 index 00000000..d5396f5a --- /dev/null +++ b/src/common/vcd.ts @@ -0,0 +1,120 @@ +// Writes signal traces as a VCD (value change dump) file, as a stream: it +// keeps only each signal's last value and writes a line when one changes, so +// memory use doesn't depend on how long the recording is. + +export interface VcdSignal { + /** the key in the state object; a `$` separates module names from the signal's */ + name: string; + /** width in bits */ + len: number; +} + +export interface VcdOptions { + /** name of the top-level scope */ + top?: string; + /** one sample is one tick of this; `1 ns` unless the caller knows better */ + timescale?: string; +} + +// flush to `write` once this many characters are waiting +const CHUNK_CHARS = 1 << 16; + +/** The nth identifier code: printable ASCII from `!` to `~`, base 94. */ +export function vcdId(n: number): string { + let s = ''; + do { + s = String.fromCharCode(33 + n % 94) + s; + n = Math.floor(n / 94) - 1; + } while (n >= 0); + return s; +} + +/** A signal's value as VCD text: `1` for a bit, `b101` for a vector (no leading zeros). */ +function formatValue(v: number | bigint, len: number, id: string): string { + if (len === 1) return (v ? '1' : '0') + id; + return 'b' + v.toString(2) + ' ' + id; +} + +/** The value as an unsigned number (a bigint past 32 bits), cut to `len` bits. */ +function normalize(v: any, len: number): number | bigint { + if (typeof v === 'bigint') return BigInt.asUintN(len, v); + if (typeof v !== 'number') return 0; + if (len <= 32) return len === 32 ? v >>> 0 : (v & ((1 << len) - 1)) >>> 0; + return BigInt.asUintN(len, BigInt(Math.trunc(v))); +} + +export class VCDWriter { + private ids: string[]; + private last: (number | bigint | undefined)[]; + private buf: string[] = []; + private bufChars = 0; + /** samples taken so far; the next one is at this time */ + samples = 0; + + constructor(private signals: VcdSignal[], private write: (chunk: string) => void, opts: VcdOptions = {}) { + this.ids = signals.map((_, i) => vcdId(i)); + this.last = signals.map(() => undefined); + this.header(opts); + } + + /** Record the signals' values at the next time step; only changes are written. */ + sample(state: { [name: string]: any }) { + const { signals, ids, last } = this; + let stamped = false; + for (let i = 0; i < signals.length; i++) { + const s = signals[i]; + const v = normalize(state[s.name], s.len); + if (v === last[i]) continue; + if (!stamped) { + // the first sample opens $dumpvars, which gives every signal a value + this.put(this.samples === 0 ? '#0\n$dumpvars\n' : '#' + this.samples + '\n'); + stamped = true; + } + last[i] = v; + this.put(formatValue(v, s.len, ids[i]) + '\n'); + } + if (this.samples === 0) this.put('$end\n'); + this.samples++; + } + + /** Write what's left, with a final time stamp so viewers show the whole recording. */ + finish() { + if (this.samples > 0) this.put('#' + this.samples + '\n'); + this.flush(); + } + + private header(opts: VcdOptions) { + const out = ['$version 8bitworkshop $end\n', `$timescale ${opts.timescale || '1 ns'} $end\n`]; + // nest the signals in scopes named by the `$`-separated parts of their names + let open: string[] = []; + out.push(`$scope module ${opts.top || 'top'} $end\n`); + this.signals.forEach((s, i) => { + const parts = s.name.split('$'); + const leaf = parts.pop() || s.name; + // a design's own name (the first part) is the top scope already + const path = parts.slice(1); + let common = 0; + while (common < open.length && common < path.length && open[common] === path[common]) common++; + for (let j = open.length; j > common; j--) out.push('$upscope $end\n'); + for (let j = common; j < path.length; j++) out.push(`$scope module ${path[j]} $end\n`); + open = path; + const range = s.len > 1 ? ` [${s.len - 1}:0]` : ''; + out.push(`$var wire ${s.len} ${this.ids[i]} ${leaf}${range} $end\n`); + }); + for (let j = open.length; j > 0; j--) out.push('$upscope $end\n'); + out.push('$upscope $end\n', '$enddefinitions $end\n'); + this.put(out.join('')); + } + + private put(s: string) { + this.buf.push(s); + this.bufChars += s.length; + if (this.bufChars >= CHUNK_CHARS) this.flush(); + } + + private flush() { + if (this.buf.length) this.write(this.buf.join('')); + this.buf = []; + this.bufChars = 0; + } +} diff --git a/src/common/waveform.ts b/src/common/waveform.ts index d2f0221c..7a55dce4 100644 --- a/src/common/waveform.ts +++ b/src/common/waveform.ts @@ -2,6 +2,8 @@ // Signal traces that a platform (verilog) exposes to a waveform view. export interface WaveformMeta { + /** the key in the design's state, when it differs from the label (see vcd.ts) */ + name? : string; label : string; len : number; input : boolean; diff --git a/src/platform/verilog.ts b/src/platform/verilog.ts index 3301525e..b8a10099 100644 --- a/src/platform/verilog.ts +++ b/src/platform/verilog.ts @@ -128,6 +128,7 @@ var VerilogPlatform = function(mainElement, options) { var trace_signals; var trace_buffer; var trace_index; + var trace_sink : ((state: {[name: string]: any}) => void) | null = null; // for virtual CRT var framex=0; @@ -348,7 +349,7 @@ var VerilogPlatform = function(mainElement, options) { ncycles |= 0; var inspect = inspect_obj != null && inspect_sym != null; // use fast trace buffer-based update? - if (sync && !trace && !inspect && (top as HDLModuleTrace).trace != null && scanlineCycles > 0) { + if (sync && !trace && !inspect && !trace_sink && (top as HDLModuleTrace).trace != null && scanlineCycles > 0) { this.updateVideoFrameFast((top as any) as HDLModuleTrace); this.updateRecorder(); return; @@ -361,6 +362,7 @@ var VerilogPlatform = function(mainElement, options) { this.snapshotTrace(); if (trace_index == trace0) trace = false; // kill trace when wraps around } + if (trace_sink) trace_sink(top.state); vidtick(); if (framex++ < videoWidth) { if (framey < videoHeight) { @@ -514,6 +516,10 @@ var VerilogPlatform = function(mainElement, options) { return (trace_index == 0); } + setTraceSink(sink: ((state: {[name: string]: any}) => void) | null) { + trace_sink = sink; + } + getSignalMetadata() : WaveformMeta[] { return trace_signals; } diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index 42d963a1..25c03f7c 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -18,6 +18,7 @@ import { fail, hasOutput, note, output, setJsonMode, setServerMode, warn } from import { EmuTarget, loadPlatform } from './emutarget'; import { RUN_SCRIPT_HELP, RunScript, parseNum } from './runscript'; import { verifyReplay } from './verifyreplay'; +import { openVcdFile } from './vcdfile'; import { buildDebugContext } from '../common/debugcontroller'; import { buildProducts } from '../common/workertypes'; import type { BuildInfo } from './debugservice'; @@ -317,7 +318,13 @@ async function doRun(args: Args, positional: string[]): Promise { const symbolFile = str(args, 'symbols'); if (symbolFile) script.addSymbols(parseSymbolFile(fs.readFileSync(symbolFile, 'utf8'))); script.startTracing(); - script.run(buildScript(args)); + // `vcd FILE` writes as it goes, so a long recording doesn't sit in memory + script.openFile = openVcdFile; + try { + script.run(buildScript(args)); + } finally { + script.finish(); + } const video = target.getVideo(); output({ diff --git a/src/tools/runscript.ts b/src/tools/runscript.ts index 19927c9a..909d8a11 100644 --- a/src/tools/runscript.ts +++ b/src/tools/runscript.ts @@ -49,6 +49,7 @@ export const RUN_SCRIPT_HELP = [ ' info - platform/machine debug info', ' paddle X Y [BUTTONS] - move the paddle to X,Y (0-255); BUTTONS is a list of 0/1, like 1 0 0', ' signals [PATH] - HDL signals under PATH (modules separated by dots; default the top)', + ' vcd FILE [MAXMB] | vcd off - record every clock\'s signal changes to a VCD file; .gz compresses; stops at MAXMB (default 1024, 0 = no limit); 8bws only', ' reset - reset the emulator', ' echo TEXT - print message', '(ADDR: number or symbol name; KEY: char, ENTER/SPACE/arrows/F1.., $hex;', @@ -160,6 +161,12 @@ export class RunScript { symbols: SymbolMap = {}; private addr2symbol: { [addr: number]: string } = {}; private probe: ProbeRecorder | null = null; + /** + * Opens the file for `vcd FILE`. Only the CLI sets this: scripts that come + * from elsewhere (the debug console, language model tools) can't write files. + */ + openFile?: (path: string, maxBytes?: number) => { write(chunk: string): void; close(): void; readonly full: boolean }; + private vcdFile: { path: string, close(): void, full: boolean } | null = null; constructor(readonly target: EmuTarget, private out = write, readonly debug = new DebugController(target)) { } @@ -186,6 +193,11 @@ export class RunScript { return true; } + /** Finish what the script left open (a VCD recording). Call when it is done. */ + finish() { + if (this.vcdFile) this.cmdVcd(['vcd', 'off']); + } + run(script: string) { for (let line of script.split(/\r?\n|;/)) { line = line.trim(); @@ -535,6 +547,28 @@ export class RunScript { for (const e of treeChildren(root, path)) this.out(`${e.name}${e.expandable ? '/' : ''} ${e.value}\n`); } + cmdVcd(tokens: string[]) { + if (tokens[1] === 'off' || tokens[1] === 'stop') { + const file = this.vcdFile; + if (!file) throw new Error('no VCD recording is on'); + this.vcdFile = null; + const clocks = this.target.stopVcd(); + file.close(); + this.out(`wrote ${clocks} clocks to ${file.path}${file.full ? ' (stopped at the size limit)' : ''}\n`); + return; + } + if (!tokens[1]) throw new Error('usage: vcd FILE [MAXMB] | vcd off'); + if (!this.openFile) throw new Error("'vcd' is not available here"); + if (!this.target.supportsVcd) throw new Error(`'${this.target.id}' has no signals to record`); + this.finish(); + // MAXMB: stop at about this size; 0 for no limit + const maxMB = tokens[2] != null ? parseFloat(tokens[2]) : NaN; + if (tokens[2] != null && !(maxMB >= 0)) throw new Error('usage: vcd FILE [MAXMB] | vcd off'); + const file = this.openFile(tokens[1], isNaN(maxMB) ? undefined : maxMB * 1048576); + this.target.startVcd(chunk => file.write(chunk), () => file.full); + this.vcdFile = { path: tokens[1], close: () => file.close(), get full() { return file.full; } }; + } + cmdReset() { this.target.reset(); this.log('reset'); } cmdEcho(tokens: string[], line: string) { this.out(line.substring(tokens[0].length).trim() + '\n'); } } @@ -566,6 +600,7 @@ const COMMANDS: { [name: string]: Command } = { 'pc': RunScript.prototype.cmdPC, 'info': RunScript.prototype.cmdInfo, 'signals': RunScript.prototype.cmdSignals, + 'vcd': RunScript.prototype.cmdVcd, 'paddle': RunScript.prototype.cmdPaddle, 'reset': RunScript.prototype.cmdReset, 'back': RunScript.prototype.cmdBack, diff --git a/src/tools/vcdfile.ts b/src/tools/vcdfile.ts new file mode 100644 index 00000000..b7b0dfeb --- /dev/null +++ b/src/tools/vcdfile.ts @@ -0,0 +1,39 @@ +// The file a VCD recording streams to (see EmuCore.startVcd). Node only. + +import * as fs from 'fs'; +import * as zlib from 'zlib'; + +export interface VcdFile { + write(chunk: string): void; + close(): void; + /** bytes written to the file so far (after compression) */ + readonly bytes: number; + /** `maxBytes` has been reached: the recording should stop */ + readonly full: boolean; +} + +/** The most the CLI writes unless told otherwise, about 400 frames of a design with video. */ +export const DEFAULT_VCD_MAX_BYTES = 1 << 30; + +/** + * Open `file` for a recording. A name ending in `.gz` is gzipped as it goes: + * each chunk is its own gzip member, which gunzip reads as one stream, so + * nothing needs flushing at the end. VCD compresses about 4x. + * `maxBytes` (0 for no limit) sets when `full` turns true; the file goes a + * little past it, since the recording stops between clocks. + */ +export function openVcdFile(file: string, maxBytes = DEFAULT_VCD_MAX_BYTES): VcdFile { + const fd = fs.openSync(file, 'w'); + const gzip = /\.gz$/i.test(file); + const f = { + bytes: 0, + get full() { return maxBytes > 0 && f.bytes >= maxBytes; }, + write(chunk: string) { + const data = gzip ? zlib.gzipSync(chunk, { level: 1 }) : Buffer.from(chunk); + fs.writeSync(fd, data); + f.bytes += data.length; + }, + close() { fs.closeSync(fd); }, + }; + return f; +} diff --git a/test/cli/testcli.js b/test/cli/testcli.js index d4e7fb8a..ed2466ab 100644 --- a/test/cli/testcli.js +++ b/test/cli/testcli.js @@ -272,6 +272,41 @@ describe('8bws CLI', function () { }); }); + describe('run --platform verilog: vcd', function () { + var dir; + before(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), '8bws-vcd-')); }); + it('should stream signal changes to a VCD file, and gzip a .gz name', function () { + var plain = path.join(dir, 'a.vcd'), gz = path.join(dir, 'a.vcd.gz'); + var out = cli('run', 'presets/verilog/ball_paddle.v', '-e', `run 1; vcd ${plain}; run 1; vcd off; vcd ${gz}; run 1; vcd off`); + assert.ok(/wrote \d+ clocks to .*a\.vcd\b/.test(out), out); + var text = fs.readFileSync(plain, 'utf8'); + assert.ok(text.startsWith('$version'), text.slice(0, 80)); + assert.ok(/\$var wire 9 \S+ hpos \[8:0\] \$end/.test(text)); + assert.ok(/^#0\n\$dumpvars$/m.test(text)); + var unzipped = require('zlib').gunzipSync(fs.readFileSync(gz)).toString('utf8'); + assert.ok(unzipped.startsWith('$version')); + assert.ok(fs.statSync(gz).size < unzipped.length / 2, 'gzip shrinks it'); + }); + it('should close the file even if the script forgets `vcd off`', function () { + var f = path.join(dir, 'b.vcd'); + cli('run', 'presets/verilog/ball_paddle.v', '-e', `vcd ${f}; run 1`); + assert.ok(/\n#\d+\n$/.test(fs.readFileSync(f, 'utf8')), 'ends with a final time stamp'); + }); + it('should stop at the size limit, between clocks', function () { + var f = path.join(dir, 'd.vcd'); + var out = cli('run', 'presets/verilog/ball_paddle.v', '-e', `vcd ${f} 0.5; run 2; vcd off`); + assert.ok(/stopped at the size limit/.test(out), out); + var size = fs.statSync(f).size; + // about 2.7MB a frame, so the half megabyte is passed by a chunk or two, not by the two frames + assert.ok(size >= 0.5 * 1048576 && size < 0.7 * 1048576, 'size ' + size); + assert.ok(/\n#\d+\n$/.test(fs.readFileSync(f, 'utf8')), 'still ends cleanly'); + }); + it('should say so when the platform has no signals', function () { + var e = cliFails('run', '--platform', 'apple2', '-e', `vcd ${path.join(dir, 'c.vcd')}`, 'test/roms/apple2/cosmic.c.rom'); + assert.ok(/no signals to record/.test(e.stdout + e.stderr), e.stdout + e.stderr); + }); + }); + describe('run: emulator control', function () { // these all reach through the Platform to its Machine (common/devices.ts) it('should reject an unknown platform', function () { diff --git a/test/unit/testvcd.ts b/test/unit/testvcd.ts new file mode 100644 index 00000000..388894e4 --- /dev/null +++ b/test/unit/testvcd.ts @@ -0,0 +1,84 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { VCDWriter, vcdId } from "../../src/common/vcd"; + +function record(signals: { name: string, len: number }[], states: any[]) { + const chunks: string[] = []; + const w = new VCDWriter(signals, c => chunks.push(c)); + for (const s of states) w.sample(s); + w.finish(); + return { text: chunks.join(''), chunks, w }; +} + +describe('VCD writer', function () { + it('makes identifier codes from printable characters', function () { + assert.equal(vcdId(0), '!'); + assert.equal(vcdId(93), '~'); + assert.equal(vcdId(94), '!!'); + const ids = new Set(Array.from({ length: 20000 }, (_, i) => vcdId(i))); + assert.equal(ids.size, 20000); + for (const id of ids) assert.ok(/^[!-~]+$/.test(id)); + }); + + it('declares signals in scopes named by their `$` paths', function () { + const { text } = record([ + { name: 'top$clk', len: 1 }, + { name: 'top$cpu$pc', len: 16 }, + { name: 'top$cpu$alu$out', len: 8 }, + { name: 'top$ram$we', len: 1 }, + ], [{}]); + const defs = text.slice(0, text.indexOf('$enddefinitions')); + assert.deepStrictEqual(defs.split('\n').filter(l => /^\$(scope|upscope|var)/.test(l)), [ + '$scope module top $end', + '$var wire 1 ! clk $end', + '$scope module cpu $end', + '$var wire 16 " pc [15:0] $end', + '$scope module alu $end', + '$var wire 8 # out [7:0] $end', + '$upscope $end', + '$upscope $end', + '$scope module ram $end', + '$var wire 1 $ we $end', + '$upscope $end', + '$upscope $end', + ]); + }); + + it('dumps every value first, then only changes', function () { + const { text } = record([{ name: 'm$a', len: 1 }, { name: 'm$b', len: 4 }], [ + { m$a: 0, m$b: 5 }, + { m$a: 0, m$b: 5 }, // nothing changed: no time stamp + { m$a: 1, m$b: 5 }, + { m$a: 1, m$b: 0 }, + ]); + const body = text.slice(text.indexOf('$enddefinitions')); + assert.equal(body.split('\n').slice(1).join('\n'), [ + '#0', '$dumpvars', '0!', 'b101 "', '$end', + '#2', '1!', + '#3', 'b0 "', + '#4', '', + ].join('\n')); + }); + + it('cuts values to the signal width and reads bigints', function () { + const { text } = record([ + { name: 'm$n', len: 4 }, { name: 'm$s', len: 8 }, { name: 'm$w', len: 40 }, { name: 'm$x', len: 32 }, + ], [{ m$n: 0x1f, m$s: -1, m$w: BigInt("0x123456789a"), m$x: -1 }]); + assert.ok(text.includes('b1111 !\n')); + assert.ok(text.includes('b11111111 "\n')); + assert.ok(text.includes('b' + BigInt("0x123456789a").toString(2) + ' #\n')); + assert.ok(text.includes('b' + '1'.repeat(32) + ' $\n')); + }); + + it('writes in chunks, so a long recording is never held whole', function () { + const chunks: string[] = []; + const w = new VCDWriter([{ name: 'm$c', len: 16 }], c => chunks.push(c)); + for (let i = 0; i < 100000; i++) w.sample({ m$c: i }); + w.finish(); + assert.ok(chunks.length > 10, `${chunks.length} chunks`); + assert.equal(w.samples, 100000); + const all = chunks.join(''); + assert.ok(all.endsWith('#100000\n')); + assert.equal(all.match(/^#/gm)!.length, 100001); + }); +});