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 7207ae1c02403917ef9066ba08756055cc8f4d69
parent 96f265c5c17b1192acfbd1ad8b0132df31dbc7d1
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  4 Nov 2026 15:39:16 -0500

Cassettes BASIC : 48 programmes au menu, auto-chargement d'un clic

Pack TOSEC (archive.org), extraites par tools/get_cartridges.py (cassettes [K7] de base,
jeux + applications, curées) dans roms/tapes/ (non versionné), embarquées dans dsp.data.
Menu à deux groupes (Cartouches / Cassettes BASIC). À la sélection d'une cassette, la page
monte ExelBASIC + la cassette puis tape elle-même LOAD"1", Échap et RUN (événements clavier
synthétiques sur #canvas, vraie touche Shift pour le " = Shift+3), avec repli en consigne.
Vérifié : Donkey Kong et Échecs-Dames se chargent et tournent.

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

Diffstat:
M.gitignore | 1+
Atest/cassettes.mjs | 19+++++++++++++++++++
Mtools/get_cartridges.py | 43+++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
4 files changed, 120 insertions(+), 11 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -8,3 +8,4 @@ web/dsp.data web/index.html .DS_Store roms/cartridges/ +roms/tapes/ diff --git a/test/cassettes.mjs b/test/cassettes.mjs @@ -0,0 +1,19 @@ +// Vérifie l'auto-chargement des cassettes : sélectionne un programme du groupe « Cassettes (BASIC) » +// et laisse la PAGE taper LOAD"1"/Échap/RUN toute seule, puis capture le résultat. +// node test/cassettes.mjs [url] [slug1.k7,slug2.k7,...] [préfixe] +import { open, sleep } from './cdp.mjs'; + +const url = process.argv[2] ?? 'http://localhost:8191/'; +const which = (process.argv[3] ?? 'donkey-kong.k7,boulderdash.k7,exel-jack.k7').split(','); +const prefix = process.argv[4] ?? '/tmp/exl100-cass'; + +const page = await open(url, { width: 900, height: 820 }); +await sleep(12000); +for (const slug of which) { + await page.evaluate(`(() => { const s=document.getElementById('program'); s.value=${JSON.stringify('tape:' + slug)}; s.dispatchEvent(new Event('change')); })()`); + await sleep(19000); // 4,5 s ExelBASIC + frappe + 7 s lecture + RUN + const name = slug.replace(/\.k7$/, ''); + console.log(`${name} :`, await page.screenshot(`${prefix}-${name}.png`)); +} +for (const l of page.logs) console.log(l); +page.close(); diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py @@ -70,6 +70,49 @@ def main(): json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f, ensure_ascii=False, indent=1) print(f"{len(titles)} cartouches dans {OUT}", file=sys.stderr) + extract_tapes(tmp) + +# Cassettes [K7] (programmes BASIC) : jeux et applications, versions de base seulement. +TAPES_OUT = os.path.join(HERE, "roms", "tapes") +TAPE_SKIP = ("[h ", "[a]", "[b", "[req 64kb]", "side b", "(beta)", "[unreleased]", "(side a)") +def extract_tapes(tmp, limit=48): + os.makedirs(TAPES_OUT, exist_ok=True) + titles = {} + for root, _, files in os.walk(tmp): + if "- [K7] " not in root: + continue + # On garde les jeux et applications, pas les démos ni compilations. + if ("Games" not in root and "Educational" not in root and "Applications" not in root): + continue + for fn in sorted(files): + if not fn.lower().endswith(".zip"): + continue + low = fn.lower() + if any(s in low for s in TAPE_SKIP): + continue + with zipfile.ZipFile(os.path.join(root, fn)) as z: + members = [m for m in z.namelist() if m.lower().endswith((".k7", ".wav"))] + if not members: + continue + data = z.read(members[0]) + if not data: + continue + title = title_of(fn) + slug = slug_of(title) + out = f"{slug}.k7" + if out in titles: # doublon de titre : on garde le premier + continue + with open(os.path.join(TAPES_OUT, out), "wb") as f: + f.write(data) + titles[out] = title + if len(titles) >= limit: + break + if len(titles) >= limit: + break + with open(os.path.join(TAPES_OUT, "titles.json"), "w", encoding="utf-8") as f: + json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f, + ensure_ascii=False, indent=1) + print(f"{len(titles)} cassettes dans {TAPES_OUT}", file=sys.stderr) if __name__ == "__main__": main() diff --git a/web/shell.html.in b/web/shell.html.in @@ -122,10 +122,12 @@ <p>Au lancement, la machine affiche le papillon arc-en-ciel et le logo <b>exelvision</b> sur fond cyan, exactement comme un EXL 100 à la mise sous tension. L'EXL 100 n'a pas de BASIC intégré : comme sur la vraie machine, tout vient d'une <b>cartouche</b> ou d'une <b>cassette</b>.</p> - <p>Choisissez un titre dans le menu <b>Programme</b> : une quinzaine de cartouches sont embarquées, - dont <b>ExelBASIC</b> (invite BASIC), <b>Tennis</b>, <b>Guppy</b>, <b>Virus</b>, <b>Exelpaint</b>… - Vous pouvez aussi <b>charger votre propre fichier</b> de cartouche (<code>.bin</code>, - <code>.rom</code>) ou de cassette (<code>.k7</code>, <code>.wav</code>) puis cliquer <b>Démarrer</b>.</p> + <p>Choisissez un titre dans le menu <b>Programme</b>. Les <b>Cartouches</b> (ExelBASIC, Tennis, Guppy, + Virus, Exelpaint…) démarrent seules. Les <b>Cassettes (BASIC)</b> — une cinquantaine de jeux et + programmes — <b>se chargent toutes seules</b> : la page lance ExelBASIC puis tape + <code>LOAD "1"</code>, <kbd>Échap</kbd> et <code>RUN</code> pour vous ; patientez quelques secondes. + Vous pouvez aussi <b>charger votre propre fichier</b> (<code>.bin</code>, <code>.rom</code>, + <code>.k7</code>, <code>.wav</code>).</p> </div> <div> <h3>Le clavier infrarouge</h3> @@ -260,22 +262,66 @@ // Menu « Programme » : cartouches embarquées dans dsp.data (/roms/cartridges/), lues par leur // titre dans titles.json. On relance l'EXL 100 avec la cartouche choisie (ou le logo seul). const programSel = $('program'); - function bootProgram(file) { + let bootToken = 0; // annule une frappe automatique en cours si on change de programme + // Cartouche (cart:) = autonome. Cassette BASIC (tape:) = on charge ExelBASIC puis la cassette, + // et la page tape elle-même la séquence de chargement de l'EXL 100 (LOAD"1", Échap, RUN). + function bootProgram(value) { + const token = ++bootToken; const args = ['--game', 'exl100']; - if (file) args.push('--tape', '/roms/cartridges/' + file); + let cassette = false; + if (value.startsWith('cart:')) { + args.push('--tape', '/roms/cartridges/' + value.slice(5)); + } else if (value.startsWith('tape:')) { + args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', '/roms/tapes/' + value.slice(5)); + cassette = true; + } args.push('/roms/exl100.zip'); launch(args); + if (cassette) autoLoadCassette(token); } programSel.onchange = () => bootProgram(programSel.value); - function populatePrograms() { + + // Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC, en événements clavier synthétiques sur le + // canevas (SDL écoute #canvas). Le " de l'EXL 100 = Shift+3 ; les modificateurs sont de vraies + // touches Shift maintenues. S'annule si un autre programme est lancé entre-temps. + async function autoLoadCassette(token) { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const alive = () => token === bootToken; + const send = (type, code, keyName, shift) => + canvas.dispatchEvent(new KeyboardEvent(type, { code, key: keyName, shiftKey: !!shift, bubbles: true, cancelable: true })); + const tap = async (code, keyName) => { if (!alive()) return; send('keydown', code, keyName, false); await sleep(70); send('keyup', code, keyName, false); await sleep(110); }; + const sh = async (code, shifted) => { + if (!alive()) return; + send('keydown', 'ShiftLeft', 'Shift', true); await sleep(80); + send('keydown', code, shifted, true); await sleep(70); send('keyup', code, shifted, true); await sleep(80); + send('keyup', 'ShiftLeft', 'Shift', false); await sleep(120); + }; + statusEl.textContent = 'ExelBASIC — chargement automatique de la cassette…'; + await sleep(4500); if (!alive()) return; // ExelBASIC prêt + canvas.focus(); + for (const [c, k] of [['KeyL', 'l'], ['KeyO', 'o'], ['KeyA', 'a'], ['KeyD', 'd']]) await tap(c, k); + await sh('Digit3', '"'); await tap('Digit1', '1'); await sh('Digit3', '"'); + await tap('Enter', 'Enter'); await sleep(1500); + await tap('Escape', 'Escape'); await sleep(7000); if (!alive()) return; // lecture (chargement rapide) + for (const [c, k] of [['KeyR', 'r'], ['KeyU', 'u'], ['KeyN', 'n']]) await tap(c, k); + await tap('Enter', 'Enter'); + if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN.'; + } + function addGroup(label, path, prefix) { let titles; - try { titles = JSON.parse(fs().readFile('/roms/cartridges/titles.json', { encoding: 'utf8' })); } - catch (e) { return; } + try { titles = JSON.parse(fs().readFile(path, { encoding: 'utf8' })); } catch (e) { return; } + const g = document.createElement('optgroup'); + g.label = label; for (const [file, title] of Object.entries(titles).sort((a, b) => a[1].localeCompare(b[1], 'fr'))) { const opt = document.createElement('option'); - opt.value = file; opt.textContent = title; - programSel.appendChild(opt); + opt.value = prefix + file; opt.textContent = title; + g.appendChild(opt); } + if (g.children.length) programSel.appendChild(g); + } + function populatePrograms() { + addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:'); + addGroup('Cassettes (BASIC)', '/roms/tapes/titles.json', 'tape:'); } const Module = window.Module = {