verilog: record to VCD

This commit is contained in:
Steven Hugg committed 2026-10-03 20:34:42 +02:00
1 parent d7e9249d69
commit 60e5577fc3
17 files changed
+613 -5

No files matched your search

+1
View File
@@ -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.
+4
View File
@@ -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.
+2
View File
@@ -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
+18
View File
@@ -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"
}
]
},
+60 -1
View File
@@ -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<string>();
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()));
+49 -1
View File
@@ -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<boolean>('muted', false);
@@ -313,10 +315,12 @@ async function getEmu(platform: string): Promise<WorkerHandle> {
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;
+86 -1
View File
@@ -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<ReturnType<Builder['build']>>;
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<EmuStatus>('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<VcdEvent>('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<EmuStatus>('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<ReturnType<Builder['build']>>;
var stops: any[];
+5
View File
@@ -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;
+58
View File
@@ -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;
+120
View File
@@ -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;
}
}
+2
View File
@@ -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;
+7 -1
View File
@@ -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;
}
+8 -1
View File
@@ -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<void> {
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({
+35
View File
@@ -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,
+39
View File
@@ -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;
}
+35
View File
@@ -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 () {
+84
View File
@@ -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);
});
});