From 8ce2bc5fc656f310d442ac8506fcdfe51e2c7f13 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 3 Oct 2026 21:23:52 +0200 Subject: [PATCH] vscode: support vector video --- CHANGELOG.md | 1 + extension/CHANGELOG.md | 2 + extension/package.json | 8 ++++ extension/src/emuworker.ts | 3 ++ extension/src/extension.ts | 9 +++- src/common/emucore.ts | 27 +++++++++-- src/common/vectorraster.ts | 88 ++++++++++++++++++++++++++++++++++++ src/tools/8bws.ts | 3 ++ test/cli/testvectorraster.js | 68 ++++++++++++++++++++++++++++ 9 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 src/common/vectorraster.ts create mode 100644 test/cli/testvectorraster.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db46abd..c766dd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md). ### Debugging and the `8bws` CLI +- Vector platforms (`vector-*`, `vectrex`) render headlessly: a software rasterizer with phosphor fade draws their lines into the frame buffer, so `--png` and the VS Code emulator panel show them. The size is `--vector-size ` (long side, default 512), `EmuCore.vectorSize`, or the extension's `8bitworkshop.vectorSize`. - New `8bws` command-line tool (`npm run cli`) to build and run programs headlessly: `build`, `run`, `verify-replay`, `profile`, `detect`, `list-platforms`, `list-tools`; scripted emulator control; `--png`, `--json`, `--symbols`, `--memdump`, `--info`; platform and main file detected from a project directory. - Platform detection no longer confuses the Atari 8-bit targets: `atari.h` means the 800 (it is guarded by `__ATARI__`), `atari5200.h` means the 5200, and a folder named for a platform breaks a near-tie. - 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. diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index cfdb869a..fb3e021b 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -5,6 +5,8 @@ - The Verilog and Verilog (VGA) platforms now appear in New Project and Open Example. - Verilog syntax highlighting. +- Vector platforms (Atari vector, Vectrex) show their video in the emulator panel. The + `8bitworkshop.vectorSize` setting sets its resolution (512 by default). - The mouse is a paddle in the emulator panel, for programs that read one (Verilog designs with `hpaddle`/`vpaddle` inputs, and platforms with paddles). - Reloading the window closes the emulator tab instead of restoring it blank. diff --git a/extension/package.json b/extension/package.json index db54dcbe..a9dbe69f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -342,6 +342,14 @@ ] } }, + "8bitworkshop.vectorSize": { + "type": "number", + "default": 512, + "minimum": 128, + "maximum": 2048, + "scope": "window", + "markdownDescription": "Pixels along the longer side of the screen for vector platforms (`vector`, `vectrex`). Takes effect on the next run." + }, "8bitworkshop.autoBuild": { "type": "string", "enum": [ diff --git a/extension/src/emuworker.ts b/extension/src/emuworker.ts index 38aebd6e..4c8561d9 100644 --- a/extension/src/emuworker.ts +++ b/extension/src/emuworker.ts @@ -108,6 +108,8 @@ export interface ScriptResult { export interface LoadOptions { /** load it stopped, as a debug session does until it is configured */ paused?: boolean; + /** pixels along the longer side of a vector platform's screen */ + vectorSize?: number; } /** DebugService methods the host may call through `debug`. */ @@ -198,6 +200,7 @@ const rpc: Rpc = new Rpc(parentPort, { stop(); clearLastKeycodeMap(); target = await loadPlatform(EMULATOR_FOR[platform] || platform); + if (opts.vectorSize) target.vectorSize = opts.vectorSize; await target.start(); service = new DebugService(target, stopped); // machines build their keyboard handler as they start diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 3b2b8017..a2b9c15c 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -198,6 +198,11 @@ function config(uri?: vscode.Uri) { return vscode.workspace.getConfiguration(CONFIG, uri); } +/** pixels along the longer side of a vector platform's screen */ +function vectorSize(): number { + return config().get('vectorSize') || 512; +} + ////// toolchains /** A local copy (toolchainPath, or the repo in development), if there is one. */ @@ -432,7 +437,7 @@ async function runScriptHeadless(target: Target, build: BuildOutcome, script: st cancel = token.onCancellationRequested(() => reject(new Error('cancelled'))); }); var run = async () => { - await worker.call('start', target.platform, build.output, build.files, { paused: true }); + await worker.call('start', target.platform, build.output, build.files, { paused: true, vectorSize: vectorSize() }); var mainPath = path.posix.basename(target.main.path); return worker.call('script', script, { ...buildProducts(build), mainPath, paths: build.paths }); }; @@ -708,7 +713,7 @@ async function startEmulator(target: Target, build: BuildOutcome, opts: { paused var worker = await getEmu(target.platform); var title = describeTarget(target); try { - emuStatus = await worker.call('start', target.platform, build.output, build.files, opts); + emuStatus = await worker.call('start', target.platform, build.output, build.files, { ...opts, vectorSize: vectorSize() }); // (the worker's first frame comes after this reply, so the panel is up in time) if (emuStatus.screen !== false || panel) openPanel(title, target.platform); vscode.commands.executeCommand('setContext', '8bitworkshop.emuOpen', true); diff --git a/src/common/emucore.ts b/src/common/emucore.ts index 0778880c..1d15bcaa 100644 --- a/src/common/emucore.ts +++ b/src/common/emucore.ts @@ -39,6 +39,7 @@ import { disassembleF8 } from "./cpu/disasmF8"; import { disassembleHuC6280 } from "./cpu/disasmHuC6280"; import { CPU6809 } from "./cpu/6809"; import * as emu from "./emu"; +import { VectorRaster, DEFAULT_VECTOR_SIZE } from "./vectorraster"; export interface VideoOutput { pixels: Uint32Array; @@ -98,7 +99,7 @@ export const DEFAULT_MAX_FRAMES = 1000; * Headless stand-ins for RasterVideo/VectorVideo/AnimationTimer. Platform * modules pick these up because Platform.start() reads them off the emu module. */ -function installHeadlessVideo() { +function installHeadlessVideo(vectorSize: number) { // 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 @@ -147,11 +148,24 @@ function installHeadlessVideo() { this.putImageData = function () { }; this.style = {}; }; - const VectorVideo: any = function () { + // the first one is the screen, like RasterVideo; the platform draws in its own + // (w x h) coordinates and the raster is scaled so its long side is `vectorSize` + const VectorVideo: any = function (_el: any, w: number, h: number) { + const raster = new VectorRaster(w, h, vectorSize); + if (!pixels) { + params = { width: raster.width, height: raster.height }; + pixels = raster.pixels; + } + this.width = w; + this.height = h; + this.persistenceAlpha = raster.persistenceAlpha; this.create = function () { this.drawops = 0; }; this.setKeyboardEvents = setKeyboardEvents; - this.clear = function () { }; - this.drawLine = function () { this.drawops++; }; + this.clear = function () { raster.persistenceAlpha = this.persistenceAlpha; raster.clear(); }; + this.drawLine = function (x1: number, y1: number, x2: number, y2: number, intensity: number, color: number) { + this.drawops++; + raster.drawLine(x1, y1, x2, y2, intensity, color); + }; }; const AnimationTimer: any = function (fps: number) { if (fps > 0) frameRate = fps; @@ -188,6 +202,9 @@ export class EmuCore { // frames run on a platform without a timeline private untimedFrames = 0; + /** pixels along the longer side of a vector platform's screen (set before start()) */ + vectorSize = DEFAULT_VECTOR_SIZE; + constructor(readonly id: string, readonly platform: Platform) { } @@ -254,7 +271,7 @@ export class EmuCore { 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(); + const headless = installHeadlessVideo(this.vectorSize); this.video = headless; try { await this.platform.start(); diff --git a/src/common/vectorraster.ts b/src/common/vectorraster.ts new file mode 100644 index 00000000..1234e5de --- /dev/null +++ b/src/common/vectorraster.ts @@ -0,0 +1,88 @@ +// Software rasterizer for vector platforms, for hosts with no canvas (the CLI +// and the VS Code extension). It mirrors VectorVideo in emu.ts: clear() fades +// the last frame, drawLine() adds a line on top. + +/** the same 8-color palette as VectorVideo.COLORS, as [r, g, b] */ +const COLORS: number[][] = [ + [0x11, 0x11, 0x11], [0x11, 0x11, 0xff], [0x11, 0xff, 0x11], [0x11, 0xff, 0xff], + [0xff, 0x11, 0x11], [0xff, 0x11, 0xff], [0xff, 0xff, 0x11], [0xff, 0xff, 0xff], +]; + +export const DEFAULT_VECTOR_SIZE = 512; + +export class VectorRaster { + readonly width: number; + readonly height: number; + /** RGBA pixels, in the byte order of a little-endian Uint32 (0xAABBGGRR) */ + readonly pixels: Uint32Array; + private bytes: Uint8Array; + private sx: number; + private sy: number; + persistenceAlpha = 0.5; + gamma = 0.8; + + /** + * @param vw,vh the coordinate space the platform draws in + * @param size pixels along the longer side of the output + */ + constructor(vw: number, vh: number, size: number) { + var scale = size / Math.max(vw, vh); + this.width = Math.max(1, Math.round(vw * scale)); + this.height = Math.max(1, Math.round(vh * scale)); + this.sx = this.width / vw; + this.sy = this.height / vh; + var buffer = new ArrayBuffer(this.width * this.height * 4); + this.pixels = new Uint32Array(buffer); + this.bytes = new Uint8Array(buffer); + this.clear(true); + } + + /** Fade the last frame toward black (all the way, if `hard`). */ + clear(hard = false) { + var keep = hard ? 0 : 1 - this.persistenceAlpha; + var b = this.bytes; + for (var i = 0; i < b.length; i += 4) { + b[i] *= keep; b[i + 1] *= keep; b[i + 2] *= keep; + b[i + 3] = 255; + } + } + + /** Add a line (in the platform's coordinates, y up) with antialiasing. */ + drawLine(x1: number, y1: number, x2: number, y2: number, intensity: number, color: number) { + if (!(intensity > 0)) return; + var alpha = Math.pow(Math.min(intensity, 255) / 255, this.gamma); + var rgb = COLORS[color & 7]; + var px1 = x1 * this.sx, py1 = this.height - y1 * this.sy; + var px2 = x2 * this.sx, py2 = this.height - y2 * this.sy; + var dx = px2 - px1, dy = py2 - py1; + if (dx == 0 && dy == 0) { + this.splat(px1, py1, rgb, alpha); + return; + } + // one point per pixel along the longer axis + var steps = Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))); + for (var i = 0; i <= steps; i++) { + var t = i / steps; + this.splat(px1 + dx * t, py1 + dy * t, rgb, alpha); + } + } + + /** Add one antialiased point: its weight is shared among the 4 pixels around it. */ + private splat(x: number, y: number, rgb: number[], w: number) { + var x0 = Math.floor(x - 0.5), y0 = Math.floor(y - 0.5); + var fx = x - 0.5 - x0, fy = y - 0.5 - y0; + this.add(x0, y0, rgb, w * (1 - fx) * (1 - fy)); + this.add(x0 + 1, y0, rgb, w * fx * (1 - fy)); + this.add(x0, y0 + 1, rgb, w * (1 - fx) * fy); + this.add(x0 + 1, y0 + 1, rgb, w * fx * fy); + } + + private add(x: number, y: number, rgb: number[], w: number) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height || w <= 0) return; + var i = (y * this.width + x) * 4; + var b = this.bytes; + b[i] = Math.min(255, b[i] + rgb[0] * w); + b[i + 1] = Math.min(255, b[i + 1] + rgb[1] * w); + b[i + 2] = Math.min(255, b[i + 2] + rgb[2] * w); + } +} diff --git a/src/tools/8bws.ts b/src/tools/8bws.ts index 25c03f7c..b0447b0d 100644 --- a/src/tools/8bws.ts +++ b/src/tools/8bws.ts @@ -212,6 +212,8 @@ function looksLikeROM(file: string): boolean { async function openTarget(args: Args, platformId: string): Promise { const target = await loadPlatform(platformId); + const vectorSize = str(args, 'vector-size'); + if (vectorSize) target.vectorSize = parseInt(vectorSize); await target.start(); const bios = str(args, 'bios'); if (bios && !target.loadBIOS(new Uint8Array(fs.readFileSync(bios)))) { @@ -590,6 +592,7 @@ function usage(error?: string): never { '--png ': 'write a screenshot of the last frame', '--symbols ': 'load a .lbl/.sym file for symbolic addresses', '--bios ': 'load a BIOS image', + '--vector-size ': 'long side of a vector platform\'s screen (default 512)', '--info': 'dump debug info and disassembly when done', '--no-warnings': 'don\'t print compiler warnings', '--memdump ': 'hexdump a hex address range', diff --git a/test/cli/testvectorraster.js b/test/cli/testvectorraster.js new file mode 100644 index 00000000..f99519f8 --- /dev/null +++ b/test/cli/testvectorraster.js @@ -0,0 +1,68 @@ + +var assert = require('assert'); +var { VectorRaster } = require('../../gen/common/vectorraster.js'); +var { loadPlatform } = require('../../gen/tools/emutarget.js'); + +function lit(r) { + var n = 0; + for (var p of r.pixels) if (p & 0xffffff) n++; + return n; +} + +// brightness of one channel (shift 0, 8, 16) summed down a pixel column +function column(r, x, shift) { + var n = 0; + for (var y = 0; y < r.height; y++) n += (r.pixels[y * r.width + x] >> shift) & 0xff; + return n; +} + +describe('VectorRaster', function () { + it('scales the platform space so the long side is the size', function () { + var r = new VectorRaster(900, 1100, 550); + assert.equal(r.width, 450); + assert.equal(r.height, 550); + }); + it('draws a bright horizontal line, y up', function () { + var r = new VectorRaster(1024, 1024, 512); + r.drawLine(0, 768, 1023, 768, 255, 7); // 3/4 up is 1/4 down: row 128 + assert(Math.abs(column(r, 100, 0) - 255) < 4); + assert.equal(r.pixels[128 * 512 + 100] & 0xffffff, r.pixels[127 * 512 + 100] & 0xffffff); + assert.equal(r.pixels[400 * 512 + 100] & 0xffffff, 0); + assert.equal(r.pixels[10 * 512 + 100] & 0xffffff, 0); + }); + it('draws a point and ignores zero intensity', function () { + var r = new VectorRaster(1024, 1024, 512); + r.drawLine(100, 100, 100, 100, 0, 7); + assert.equal(lit(r), 0); + r.drawLine(100, 100, 100, 100, 255, 7); + assert(lit(r) > 0); + }); + it('fades by persistence on clear and wipes on a hard clear', function () { + var r = new VectorRaster(256, 256, 256); + r.drawLine(0, 128, 255, 128, 255, 7); + var before = column(r, 100, 0); + r.clear(); + var after = column(r, 100, 0); + assert(Math.abs(after - before / 2) < 4); + r.clear(true); + assert.equal(lit(r), 0); + }); + it('uses the palette', function () { + var r = new VectorRaster(256, 256, 256); + r.drawLine(0, 128, 255, 128, 255, 2); // green + assert(column(r, 100, 8) > 200); + assert(column(r, 100, 0) < 50); + }); +}); + +describe('headless vector platform', function () { + this.timeout(60000); + it('has a screen of the configured size', async function () { + var target = await loadPlatform('vector-ataricolor'); + target.vectorSize = 256; + await target.start(); + var video = target.getVideo(); + assert.equal(video.width, 256); + assert.equal(video.height, 256); + }); +});