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:
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 = {