mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 00:02:16 +00:00
vscode: fixed sticky emulator, better emulator keys
This commit is contained in:
1 parent
4b9a6126f7
commit
94e331a43a
7 files changed
+128
-38
No files matched your search
+48
-2
@@ -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
|
||||
|
||||
@@ -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' }] };
|
||||
}
|
||||
|
||||
@@ -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">×</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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user