exl100

Émulateur Exelvision EXL 100 (1984) dans le navigateur : dsp-cpp en WebAssembly, cartouches et cassettes BASIC
git clone https://git.schurger.org/exl100.git
Log | Files | Refs | README

commit f1331831fe003c6d1297acfc686756f1b6cdfd4d
parent e4e47b5faf95b0ea96c41fec60f0e9c3513ac23f
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 15:10:24 -0500

Page : l'émulateur capture le clavier au clic (centrage, message, plus de défilement) ; ExelBASIC inséré d'office pour les disquettes .BAS

Clic sur l'écran : focus, scrollIntoView centré, message « Le clavier est pris par l'émulateur… » ; espace,
flèches, Page, Début/Fin et Tab ne font plus défiler la page tant que le canevas a le focus. Le répertoire
EXELDOS d'une image .fd est lu dans la page (entrées de 18 octets à l'AU 1) : des fichiers .BAS font insérer
la cartouche ExelBASIC (vérifié : LOADRUN PING.BAS lance le programme). Test focus-grab.mjs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9

Diffstat:
Atest/focus-grab.mjs | 27+++++++++++++++++++++++++++
Mweb/shell.html.in | 46+++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/test/focus-grab.mjs b/test/focus-grab.mjs @@ -0,0 +1,27 @@ +// Clic sur l'écran : focus + centrage + message « clavier pris » ; la barre d'espace et les flèches ne font +// plus défiler la page tant que l'émulateur a le focus ; un clic ailleurs rend le clavier. +// node test/focus-grab.mjs [url] +import { open, sleep } from './cdp.mjs'; + +const url = process.argv[2] ?? 'http://127.0.0.1:8196/'; +const page = await open(url, { width: 900, height: 700 }); +await sleep(6000); +const state = () => page.evaluate(`({ y: Math.round(scrollY), grab: !document.getElementById('grab').hidden, focus: document.activeElement && document.activeElement.id })`); +console.log('départ :', JSON.stringify(await state())); +await page.evaluate("document.getElementById('canvas').click()"); +await sleep(1200); +const afterClick = await state(); +console.log('après clic :', JSON.stringify(afterClick)); +const y0 = afterClick.y; +for (const [k, code, vk] of [[' ', 'Space', 32], ['ArrowDown', 'ArrowDown', 40], ['PageDown', 'PageDown', 34]]) { + await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(60); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(300); +} +const afterKeys = await state(); +console.log('après espace/flèche/page :', JSON.stringify(afterKeys), afterKeys.y === y0 ? '=> pas de défilement' : '=> A DÉFILÉ'); +await page.evaluate("document.getElementById('canvas').blur()"); +await sleep(200); +console.log('après blur :', JSON.stringify(await state())); +page.close(); diff --git a/web/shell.html.in b/web/shell.html.in @@ -40,6 +40,7 @@ width: 100% !important; height: auto !important; aspect-ratio: 4 / 3; max-height: calc(100vh - 150px); object-fit: contain; } #canvas:focus { box-shadow: 0 0 0 2px var(--accent); } + #grab { text-align: center; color: var(--accent); font-size: .85rem; margin: 8px 0 0; } #status { text-align: center; color: var(--muted); font-size: .85rem; margin: 8px 0 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; min-height: 1.4em; } @@ -215,6 +216,7 @@ <section class="screen"> <div class="bezel"><canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas></div> + <p id="grab" hidden>⌨️ Le clavier est pris par l'émulateur — cliquez en dehors de l'écran pour le rendre à la page.</p> <div id="status">Chargement…</div> </section> @@ -249,8 +251,10 @@ <p>Le lecteur de disquettes 3"½ <b>Exeldisk</b> (contrôleur WD1770, 640 Ko par disquette) est émulé d'après les équates du devkit et son ROM d'amorçage. Choisissez une disquette dans la bibliothèque ou déposez une image <code>.fd</code> : l'EXL 100 amorce dessus (« exelvision BOOT V1.5 ») et charge - <b>EXELDOS</b> ; à l'invite <code>[A]</code>, <code>DIR</code>, <code>HELP</code>, <code>LOADRUN</code>… - Les écritures restent en mémoire le temps de la session.</p> + <b>EXELDOS</b> ; à l'invite <code>[A]</code>, <code>DIR</code>, <code>HELP</code>, <code>EXEC</code>… + Si la disquette contient des programmes <code>.BAS</code>, la cartouche ExelBASIC est insérée + d'office (<code>LOADRUN NOM.BAS</code> les lance) ; les binaires d'<code>EXEC</code> se chargent dans + l'Exelmémoire. Les écritures restent en mémoire le temps de la session.</p> <h3>Le clavier infrarouge</h3> <p>Le clavier et les manettes de l'EXL 100 étaient sans fil, reliés en infrarouge. Ici c'est votre clavier d'ordinateur, aux positions de MAME. Cliquez d'abord dans l'écran pour lui donner le focus.</p> @@ -342,6 +346,7 @@ if (e !== 'unwind' && !(e && e.name === 'ExitStatus')) throw e; } canvas.focus(); + $('grab').hidden = false; cramRestore(); cramAutosave(); } @@ -430,7 +435,20 @@ driverSel.onchange = () => { romInput.value = ''; startSelected(true); }; romInput.onchange = () => { if (waitingForRom || driverSel.value) { waitingForRom = false; startSelected(false); } }; $('start').onclick = () => startSelected(true); - canvas.onclick = () => canvas.focus(); + // Clic sur l'écran : l'émulateur prend le clavier (focus), se centre dans la fenêtre, et le dit. Tant qu'il + // a le focus, les touches qui font défiler la page (espace, flèches, Page, Début/Fin, Tab) lui restent. + const grabEl = $('grab'); + canvas.onclick = () => { + canvas.focus(); + grabEl.hidden = false; + canvas.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + canvas.addEventListener('focus', () => { grabEl.hidden = false; }); + canvas.addEventListener('blur', () => { grabEl.hidden = true; }); + canvas.addEventListener('keydown', (e) => { + if (e.key === ' ' || e.key === 'Tab' || e.key === 'Home' || e.key === 'End' || + e.key.startsWith('Arrow') || e.key.startsWith('Page')) e.preventDefault(); + }, { capture: true }); // Liste « Programmes » : cartouches et cassettes embarquées dans dsp.data, chacune avec sa source // (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo). @@ -475,6 +493,9 @@ return; } keepCram = true; + let basic = false; + try { basic = diskNeedsBasic(fs().readFile('/roms/disks/' + value.slice(5))); } catch (e) {} + if (basic) args.push('--tape', '/roms/cartridges/exelbasic.bin'); // programmes .BAS : cartouche insérée args.push('--tape', '/roms/disks/' + value.slice(5)); } else if (value.startsWith('cram:')) { // Volume Exelmémoire de la bibliothèque. Programmes EXL 100 (ceux de webmo : code machine qui @@ -721,6 +742,24 @@ fs().writeFile(path, data); return path; } + // Répertoire EXELDOS d'une image .fd : AU de 512 octets, répertoire à l'AU 1 (en-tête $D0 $0D, entrées de + // 18 octets = nom 13 + extension 3 + AU du FDR 2, chaînage par les 2 derniers octets de l'AU, fin si > $7FFF). + // S'il y a des fichiers .BAS, la disquette veut la cartouche ExelBASIC (LOADRUN, APPLI.BAS, INIBAS). + function diskNeedsBasic(data) { + try { + let au = 1; + for (let n = 0; n < 64 && au > 0 && au < 0x500; n++) { + const o = au * 512; + if (data[o] !== 0xd0 || data[o + 1] !== 0x0d) break; + for (let e = o + 2; e + 18 <= o + 510; e += 18) { + if (data[e] === 0 || data[e] === 0xff) continue; + if (String.fromCharCode(data[e + 13], data[e + 14], data[e + 15]) === 'BAS') return true; + } + au = (data[o + 510] << 8) | data[o + 511]; + } + } catch (e) {} + return false; + } async function openMedia(file) { if (!file) return; const data = new Uint8Array(await file.arrayBuffer()); @@ -760,6 +799,7 @@ const path = await writeMedia(file, data); const args = ['--game', gameName]; if (d.kind === 'cassette') args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', path); + else if (d.kind === 'disquette') { keepCram = true; if (diskNeedsBasic(data)) args.push('--tape', '/roms/cartridges/exelbasic.bin'); args.push('--tape', path); } else args.push('--tape', path); args.push(biosZip); launch(args);