vscode: fixed sticky emulator, better emulator keys

This commit is contained in:
Steven Hugg committed 2026-09-26 12:49:37 +02:00
1 parent 4b9a6126f7
commit 94e331a43a
7 files changed
+128 -38

No files matched your search

+48 -2
View File
@@ -1383,7 +1383,8 @@ CodeMirror, no jQuery.
**First-time players.** Writing the quickstart showed two things a new
user can't guess:
- *Focus.* Keys go to the emulator only while the panel has focus. When
it doesn't, dim the canvas and show "Click to play"; the webview's
it does, the controls hint fades in, as in the IDE; it fades out on
blur (a "Click to play" overlay was too much). The webview's
`focus`/`blur` events drive it. Keys never go to the emulator while
the editor has focus.
- *Controls.* Show a one-line controls hint under the canvas ("Arrows:
@@ -2190,9 +2191,13 @@ do them when a platform is the priority (VCS needs 3–4).
- Each Run starts a new emulator worker: platforms keep global state
(Javatari deletes its own `start()`), so `emuworker` refuses a
second `start`. A rebuild with an identical ROM doesn't reload.
- A hidden panel stops the worker's frames (`setVisible`) without
changing its paused/running state, so showing it resumes only what
was running. `Builder` keeps each main file's last output, so Run
works when the build is unchanged (e.g. running twice).
- Control hints moved out of `index.html` into `src/common/controls.ts`;
the IDE renders them from there, and the emulator panel shows them in
a bar you can hide, with "Click to play" when it lacks focus.
a bar you can hide, styled like the IDE's and shown while it has focus.
Platforms with no entry get hints generated from their key map
(`describeControls` in `emu.ts`).
- Changed from the plan: "Review" is a checklist quick pick, not a
@@ -2233,6 +2238,47 @@ do them when a platform is the priority (VCS needs 3–4).
5. **Packaging.** Asset-root override, lazy platform loading, binaryen
dynamic import, base + per-family packs, VSIX/CI.
### Plan: fuzzing
Fuzz the parts that don't need VS Code: grammars, the build and emulator
workers, and the pure helpers. The repo already has `jsfuzz` harnesses
(`fuzzbasic`, `fuzzhdl` in the root `package.json`).
Each target is a mocha test in `extension/test/` with a fixed seed and a
small iteration count, so CI runs it. `FUZZ_ITERS=n` runs longer locally and
writes failing inputs to `out/fuzz/` for replay. If random mutation stops
finding bugs, drive targets 2 and 3 with `jsfuzz`'s coverage guidance.
In order:
1. **Grammars.** VS Code tokenizes every line as you type, so a regex that
backtracks badly freezes the editor. Feed mutated lines from preset asm
files, and random punctuation-heavy strings, through
`scripts/tmtokenize.ts`. Check: each line tokenizes in a few ms, and the
tokens cover the line with no gaps or overlaps.
2. **Build worker.** Mutate preset sources (delete lines, swap tokens,
truncate, insert junk bytes) and build them with `Builder`
(`buildcore.ts`). Check: every build ends with output or errors within a
timeout; the worker survives for the next build; every error names a real
file and a line inside it. Covers the compilers' wasm, error parsing,
listing and symbol parsing, and diagnostics.
3. **Emulator worker.** Load random or mutated ROMs on each platform, then
send random keys, pause/step/reset and visibility changes. Check: frames
keep coming, status stays valid, and the worker never dies silently.
Reaches the CPU emulators' illegal-opcode and out-of-range memory paths.
4. **Property tests** (e.g. `fast-check`) for pure functions: detection
over random file trees (`src/common/detect.ts`, `projectinfo.ts`), path
edge cases in `isInside` (`..`, trailing slashes, letter case), and
`patchHeaderForClang` (applying it twice gives the same result, and
headers with nothing to patch come through unchanged).
5. **Random command sequences** (later, nightly). Under
`@vscode/test-electron`, open presets, build, run, close the panel and
change settings in random order. Catches lifecycle bugs (a disposed
panel, the build scheduler running after deactivate), but it's slow and
flaky.
Skip the webview messages: there are three types (`key`, `frameDone`,
`controlsVisible`), and a field check covers them.
## 10. Risks and open questions
- **cwd / asset root.** Worker shims assume repo-root cwd; must be made
+9 -2
View File
@@ -53,6 +53,8 @@ export class Builder {
private queue: Promise<any> = Promise.resolve();
private initialized = false;
private preloaded = new Set<string>();
/** the last successful outcome per main file, for unchanged builds */
private last = new Map<string, BuildOutcome>();
constructor(readonly rootDir: string) { }
@@ -77,6 +79,7 @@ export class Builder {
getToolForFilename: getTool,
}, deps);
var paths = [req.mainPath].concat(deps.map(d => d.path));
var key = `${req.platform}/${tool}/${req.mainPath}`;
var result: WorkerResult;
try {
for (var t of preloads) {
@@ -90,8 +93,10 @@ export class Builder {
return { success: false, tool, paths, diagnostics: [{ path: req.mainPath, line: 0, msg: String(e && e.message || e) }] };
}
if (!result || ('unchanged' in result && result.unchanged)) {
return { success: true, tool, paths, diagnostics: [], unchanged: true };
// the worker skips unchanged builds, but Run still needs the output
return { ...this.last.get(key), success: true, tool, paths, diagnostics: [], unchanged: true };
}
this.last.delete(key);
if ('errors' in result && result.errors && result.errors.length) {
var toPath = (err: WorkerError) => (err.path && filename2path[err.path]) || err.path || req.mainPath;
return {
@@ -103,10 +108,12 @@ export class Builder {
// listings stay raw: SourceFile objects don't survive postMessage, so
// the receiver runs projectcore.processListings on them
var r = result as any;
return {
var outcome: BuildOutcome = {
success: true, tool, paths, diagnostics: [],
output: r.output, listings: r.listings, symbolmap: r.symbolmap, segments: r.segments,
};
this.last.set(key, outcome);
return outcome;
}
return { success: false, tool, paths, diagnostics: [{ path: req.mainPath, line: 0, msg: 'Unknown build result' }] };
}
+13 -17
View File
@@ -72,19 +72,19 @@ function getHtml(controlsVisible: boolean) {
canvas:focus { box-shadow: 0 0 0 1px var(--vscode-focusBorder); }
#status { position: absolute; left: 8px; top: 6px; font: 12px var(--vscode-font-family); color: #ccc;
text-shadow: 0 0 3px #000; pointer-events: none; }
/* keys only reach the emulator while it has focus, so say so */
#focus { position: absolute; inset: 0; bottom: var(--bar, 0px); display: none; align-items: center; justify-content: center;
font: 14px var(--vscode-font-family); color: #fff; background: rgba(0,0,0,0.45); cursor: pointer; }
body.unfocused #focus { display: flex; }
#focus span { padding: 6px 12px; border-radius: 4px; background: rgba(0,0,0,0.6); }
#bar { position: absolute; left: 0; right: 0; bottom: 0; min-height: 24px; display: none; align-items: center; justify-content: center;
flex-wrap: wrap; gap: 4px 14px; padding: 3px 28px; box-sizing: border-box;
font: 12px var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editorWidget-background, #222); }
body.controls #bar { display: flex; }
.key { display: inline-block; padding: 0 4px; margin-right: 2px; border: 1px solid var(--vscode-widget-border, #666);
border-radius: 3px; font-family: var(--vscode-editor-font-family, monospace); white-space: pre; line-height: 1.3; }
/* the IDE's control hints (css/ui.css): key caps on black, shown while
the emulator has focus, since keys only reach it then */
#bar { position: absolute; left: 0; right: 0; bottom: 0; display: none; align-items: center; justify-content: center;
flex-wrap: wrap; padding: 2px 28px; box-sizing: border-box; background: #000;
font: 11px var(--vscode-font-family); opacity: 0; transition: opacity 200ms; }
body.controls.hascontrols #bar { display: flex; }
body:not(.unfocused) #bar { opacity: 1; }
.def { color: #ccc; white-space: nowrap; line-height: 2em; }
.key { border: 2px solid rgba(0,0,0,0.2); border-radius: 0.6em; padding: 0.2em 0.5em; margin-left: 1em;
background-color: #eee; color: #666; white-space: pre; }
.key + .key { margin-left: 0.25em; }
#hide { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); cursor: pointer; opacity: 0.7; border: 0;
background: none; color: inherit; font-size: 14px; }
background: none; color: #ccc; font-size: 14px; }
#show { position: absolute; right: 6px; bottom: 4px; display: none; cursor: pointer; font: 11px var(--vscode-font-family);
color: #ccc; background: rgba(0,0,0,0.5); border: 0; border-radius: 3px; padding: 2px 6px; }
body.hascontrols:not(.controls) #show { display: block; }
@@ -93,7 +93,6 @@ function getHtml(controlsVisible: boolean) {
<body class="${controlsVisible ? 'controls' : ''}">
<div id="wrap"><canvas id="screen" tabindex="0" width="1" height="1"></canvas></div>
<div id="status"></div>
<div id="focus"><span>Click to play</span></div>
<div id="bar"><span id="hints"></span><button id="hide" title="Hide controls">&times;</button></div>
<button id="show" title="Show controls">Controls</button>
<script nonce="${nonce}">
@@ -142,6 +141,7 @@ function getHtml(controlsVisible: boolean) {
hintsEl.textContent = '';
for (const h of hints || []) {
const def = document.createElement('span');
def.className = 'def';
for (const k of h.keys) {
const cap = document.createElement('span');
cap.className = 'key';
@@ -152,9 +152,6 @@ function getHtml(controlsVisible: boolean) {
hintsEl.appendChild(def);
}
document.body.classList.toggle('hascontrols', !!(hints && hints.length));
if (!hints || !hints.length) {
hintsEl.textContent = 'Keys go to the emulator while it has focus';
}
fit();
}
@@ -166,7 +163,6 @@ function getHtml(controlsVisible: boolean) {
let ratio = aspect || w / h;
if (sideways) ratio = 1 / ratio;
const barH = document.body.classList.contains('controls') ? bar.offsetHeight : 0;
document.body.style.setProperty('--bar', barH + 'px');
document.getElementById('wrap').style.bottom = barH + 'px';
const W = window.innerWidth, H = window.innerHeight - barH;
let dw = W, dh = W / ratio;
+22 -6
View File
@@ -37,6 +37,8 @@ setHaltHandler(err => halt(err?.message || 'Program halted'));
let target: EmuTarget | null = null;
let running = false;
/** the panel is hidden: don't run frames, but keep `running` for when it shows */
let hidden = false;
let timer: NodeJS.Timeout | null = null;
let nextTime = 0;
let controls: ControlHint[] = [];
@@ -78,6 +80,11 @@ const rpc = new Rpc(parentPort, {
resume();
return status();
},
setVisible(visible: boolean) {
hidden = !visible;
if (hidden) stopTimer();
else schedule();
},
key(key: number, code: number, flags: number) {
try {
target?.setKeyInput(key, code, flags);
@@ -99,16 +106,26 @@ function status(): EmuStatus | null {
function resume() {
if (!target || running) return;
running = true;
nextTime = performance.now();
tick();
schedule();
rpc.emit('status', status());
}
function pause() {
running = false;
stopTimer();
rpc.emit('status', status());
}
/** Start ticking from now, if running, shown, and not already ticking. */
function schedule() {
if (!running || hidden || timer) return;
nextTime = performance.now();
tick();
}
function stopTimer() {
if (timer) clearTimeout(timer);
timer = null;
rpc.emit('status', status());
}
function stop() {
@@ -119,15 +136,14 @@ function stop() {
function halt(message: string) {
if (!running || !target) return;
running = false;
if (timer) clearTimeout(timer);
timer = null;
stopTimer();
sendFrame();
rpc.emit('status', { ...status(), state: 'halted', message });
}
function tick() {
timer = null;
if (!running || !target) return;
if (!running || hidden || !target) return;
var interval = 1000 / target.frameRate;
var now = performance.now();
var frames = 0;
+2 -11
View File
@@ -28,7 +28,6 @@ let builds: WorkerHandle | undefined;
let emu: WorkerHandle | undefined;
let panel: EmulatorPanel | undefined;
let emuStatus: EmuStatus | null = null;
let pausedByHide = false;
let nextBuildId = 1;
const readers = new Map<number, (rel: string) => Promise<Uint8Array | null>>();
@@ -335,16 +334,8 @@ async function startEmulator(target: Target, rom: any) {
panel = new EmulatorPanel({
onKey: (key, code, flags) => { emu?.call('key', key, code, flags); },
onControlsVisible: visible => context.globalState.update('controlsVisible', visible),
onVisible: visible => {
// don't burn CPU on a hidden screen
if (!visible && emuStatus?.state === 'running') {
pausedByHide = true;
emu?.call('pause');
} else if (visible && pausedByHide) {
pausedByHide = false;
emu?.call('resume');
}
},
// don't burn CPU on a hidden screen
onVisible: visible => { emu?.call('setVisible', visible); },
onDispose: () => {
panel = undefined;
emuStatus = null;
+15
View File
@@ -71,4 +71,19 @@ describe('extension buildcore', function () {
assert.equal(r.diagnostics[0].path, 'game.a');
assert.equal(r.diagnostics[0].line, 5);
});
// Run builds first, so running the same file twice gets an unchanged build
it('returns the previous output when nothing changed', async function () {
var src = '#include "neslib.h"\nvoid main(void) { ppu_on_all(); while (1) ; }\n';
var req = () => ({
platform: 'nes', mainPath: 'same.c', mainText: src,
files: new ProjectFileProvider(project({ 'same.c': src }), ROOT, 'nes'),
});
var first = await builder.build(req());
assert.ok(first.output);
var second = await builder.build(req());
assert.ok(second.unchanged);
assert.deepEqual(second.output, first.output);
assert.ok(second.listings);
});
});
+19
View File
@@ -70,6 +70,25 @@ describe('extension emuworker', function () {
await waitForFrames(5);
});
it('stops while hidden without changing its paused state', async function () {
await rpc.call('start', 'nes', rom('nes/shoot2.c.rom'));
await waitForFrames(5);
await rpc.call('setVisible', false);
var n = frames.length;
await new Promise(r => setTimeout(r, 100));
assert.equal(frames.length, n);
assert.equal((await rpc.call<EmuStatus>('status')).state, 'running');
await rpc.call('setVisible', true);
await waitForFrames(5);
// a pause while hidden stays paused when shown
await rpc.call('setVisible', false);
await rpc.call('pause');
await rpc.call('setVisible', true);
n = frames.length;
await new Promise(r => setTimeout(r, 100));
assert.equal(frames.length, n);
});
// Platforms keep global state (Javatari deletes its own start()), so a
// worker runs one emulator; the extension starts a new worker for each run.
it('runs the VCS once per worker, and says so on a second start', async function () {