commit 20685ca848b0b7c749cc072ac2121eb36ca24837
parent 7871d5a663cf913e757ba6b2ea717beee4edde94
Author: Jean Schurger <jean@schurger.org>
Date: Wed, 4 Nov 2026 15:04:06 -0500
Menu Programme : 15 cartouches EXL 100 embarquées, exécution sans téléchargement
Pack TOSEC (archive.org) extrait par tools/get_cartridges.py dans roms/cartridges/ (non
versionné), embarqué dans dsp.data ; CRC identiques aux hachages MAME. La page lit
titles.json depuis le système de fichiers et offre un menu Programme (ExelBASIC, Tennis,
Guppy, Virus, Exelpaint…) ; chaque choix relance l'EXL 100 avec la cartouche en --tape.
Sources citées en pied (Exelvision, TOSEC/archive.org, ti99.com, MAME, dsp-cpp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
5 files changed, 152 insertions(+), 9 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -7,3 +7,4 @@ web/dsp.wasm
web/dsp.data
web/index.html
.DS_Store
+roms/cartridges/
diff --git a/Makefile b/Makefile
@@ -19,19 +19,25 @@ DOCKER = docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -e EM_CAC
CMFLAGS = -DCMAKE_BUILD_TYPE=Release -DDSP_DRIVERS=exelv -DDSP_PER_DRIVER_TARGETS=OFF \
-DDSP_WEB_ROMS_DIR=/work/roms "-DDSP_WEB_ARGS=--game exl100 /roms/exl100.zip"
-.PHONY: all upstream build test serve deploy install-vhost clean distclean
+.PHONY: all upstream cartridges build test serve deploy install-vhost clean distclean
all: build
+# Cartouches EXL 100 (pack TOSEC sur archive.org) extraites dans roms/cartridges/ ; non versionnées.
+# La page les embarque dans dsp.data et les propose au menu, sans téléchargement par fichier.
+cartridges: roms/cartridges/titles.json
+roms/cartridges/titles.json: tools/get_cartridges.py
+ python3 -I tools/get_cartridges.py
+
# Clone d'amont épinglé (idempotent)
upstream:
@test -d upstream/.git || git clone -q $(UPSTREAM_URL) upstream
@git -C upstream fetch -q --depth 1 origin $(UPSTREAM_REV) && git -C upstream checkout -q $(UPSTREAM_REV)
# Construction web : dsp.html/js/wasm/data, puis recopiés dans web/ (index.html = dsp.html).
-build: upstream web/index.html
+build: upstream cartridges web/index.html
-web/index.html: $(wildcard roms/*) web/shell.html.in Makefile
+web/index.html: $(wildcard roms/*) roms/cartridges/titles.json web/shell.html.in Makefile
cp web/shell.html.in upstream/web/shell.html.in
# Correctif de portabilité Emscripten : mpeg_audio.cpp (musique Sega Model 3, hors sujet ici)
# appelle abort() sans inclure <cstdlib>, que la libc++ d'Emscripten ne fournit pas en transitif.
diff --git a/test/programs.mjs b/test/programs.mjs
@@ -0,0 +1,31 @@
+// Vérifie le menu « Programme » : compte les cartouches embarquées, puis en charge quelques-unes
+// (via le <select>) et capture l'écran de chacune.
+// node test/programs.mjs [url] [préfixe]
+import { open, sleep } from './cdp.mjs';
+
+const url = process.argv[2] ?? 'http://localhost:8191/';
+const prefix = process.argv[3] ?? '/tmp/exl100-prog';
+
+const page = await open(url, { width: 900, height: 820 });
+await sleep(12000); // boot logo + FS prêt
+
+const opts = await page.evaluate(`Array.from(document.getElementById('program').options).map(o => o.value)`);
+const titles = await page.evaluate(`Array.from(document.getElementById('program').options).map(o => o.textContent)`);
+console.log('Programmes au menu :', opts.length - 1); // moins le logo
+console.log('Titres :', titles.slice(1).join(', '));
+
+async function boot(file, name, secs = 10) {
+ await page.evaluate(`(() => { const s = document.getElementById('program'); s.value = ${JSON.stringify(file)}; s.dispatchEvent(new Event('change')); })()`);
+ await sleep(secs * 1000);
+ console.log(`${name} :`, await page.screenshot(`${prefix}-${name}.png`));
+}
+
+await boot('exelbasic.bin', 'exelbasic', 12);
+await boot('tennis.bin', 'tennis', 12);
+await boot('guppy.bin', 'guppy', 12);
+await boot('', 'logo', 8);
+
+for (const l of page.logs) console.log(l);
+page.close();
+const errored = page.logs.some((l) => /abort|uncaught|cannot load/i.test(l));
+if (opts.length < 10 || errored) { console.error('ÉCHEC'); process.exit(1); }
diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Télécharge le pack TOSEC EXL 100 (archive.org) et en extrait les cartouches [ROM]
+dans roms/cartridges/<slug>.bin, avec roms/cartridges/titles.json (slug.bin -> titre).
+
+Ces cartouches sont du logiciel Exelvision (société dissoute), préservé par TOSEC. Elles ne
+sont PAS versionnées (roms/cartridges/ est dans .gitignore) ; la page les embarque dans dsp.data
+et les exécute sans offrir de téléchargement par fichier. Lancé par « make cartridges ».
+
+Usage : python3 -I tools/get_cartridges.py
+"""
+import io
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import urllib.request
+import zipfile
+import zlib
+
+TOSEC_URL = ("https://ia600708.us.archive.org/6/items/pack-roms-exelvision-exl-100/"
+ "Exelvision%20EXL%20100%20%5BTOSEC%5D.rar")
+HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+OUT = os.path.join(HERE, "roms", "cartridges")
+
+# Un titre lisible depuis un nom TOSEC « ExelBASIC+ (198x)(Exelvision)(FR) » -> « ExelBASIC+ ».
+def title_of(name):
+ base = os.path.splitext(os.path.basename(name))[0]
+ base = re.split(r"\s*\(", base, maxsplit=1)[0].strip()
+ return base or os.path.splitext(os.path.basename(name))[0]
+
+def slug_of(title):
+ s = title.lower().replace("+", "-plus").replace("é", "e").replace("è", "e")
+ s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
+ return s or "cart"
+
+def main():
+ rar = os.path.join(tempfile.gettempdir(), "exl100-tosec.rar")
+ if not os.path.exists(rar) or os.path.getsize(rar) < 1_000_000:
+ print(f"Téléchargement {TOSEC_URL}", file=sys.stderr)
+ req = urllib.request.Request(TOSEC_URL, headers={"User-Agent": "Mozilla/5.0"})
+ with urllib.request.urlopen(req, timeout=120) as r, open(rar, "wb") as f:
+ f.write(r.read())
+ # Extraction complète puis filtrage des seuls dossiers de cartouches « - [ROM] ».
+ with tempfile.TemporaryDirectory() as tmp:
+ subprocess.run(["7z", "x", "-y", f"-o{tmp}", rar], check=True, stdout=subprocess.DEVNULL)
+ os.makedirs(OUT, exist_ok=True)
+ titles = {}
+ for root, _, files in os.walk(tmp):
+ if "- [ROM] " not in root:
+ continue
+ for fn in sorted(files):
+ if not fn.lower().endswith(".zip"):
+ continue
+ with zipfile.ZipFile(os.path.join(root, fn)) as z:
+ members = [m for m in z.namelist() if m.lower().endswith((".bin", ".rom"))]
+ if not members:
+ continue
+ data = z.read(members[0])
+ title = title_of(fn)
+ slug = slug_of(title)
+ out = f"{slug}.bin"
+ with open(os.path.join(OUT, out), "wb") as f:
+ f.write(data)
+ titles[out] = title
+ print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title}",
+ file=sys.stderr)
+ with open(os.path.join(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)} cartouches dans {OUT}", file=sys.stderr)
+
+if __name__ == "__main__":
+ main()
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -90,7 +90,10 @@
</label>
<label class="hidden">Driver <select id="driver"><option value="">…</option></select></label>
<label class="hidden">ROM <input id="rom" type="file"></label>
- <label>Cartouche ou cassette
+ <label>Programme
+ <select id="program"><option value="">— Logo Exelvision (aucune cartouche) —</option></select>
+ </label>
+ <label>Charger un fichier
<select id="mediatype">
<option value="--tape">cartouche / cassette</option>
<option value="--cart">cartouche (--cart)</option>
@@ -119,9 +122,10 @@
<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>Pour aller plus loin, chargez un média : choisissez un fichier de cartouche
- (<code>.bin</code>, <code>.rom</code>) ou de cassette (<code>.k7</code>, <code>.wav</code>) puis
- cliquez <b>Démarrer</b>. ExelBASIC donne une invite BASIC ; les jeux d'époque se chargent pareil.</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>
</div>
<div>
<h3>Le clavier infrarouge</h3>
@@ -145,8 +149,12 @@
</main>
<footer>
- <p>EXL 100 et logiciels © Exelvision (1984), société dissoute depuis ; ROM système préservée par le projet MAME.
- Émulation : <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> de Chusogar. Page de Jean Schurger, 2026.</p>
+ <p><b>Sources.</b> EXL 100 et logiciels © Exelvision (1984), société dissoute. ROM système et cartouches
+ préservées par le projet <a href="https://www.mamedev.org/">MAME</a>, le pack <b>TOSEC</b> sur
+ <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> et le projet de
+ préservation Exelvision de <a href="https://www.ti99.com/exelvision/">ti99.com</a>. Émulation :
+ <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> de Chusogar (d'après MAME), compilée en
+ WebAssembly avec <a href="https://emscripten.org/">Emscripten</a>. Page de Jean Schurger, 2026.</p>
</footer>
<script>
@@ -249,6 +257,27 @@
$('start').onclick = () => startSelected(true);
canvas.onclick = () => canvas.focus();
+ // 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) {
+ const args = ['--game', 'exl100'];
+ if (file) args.push('--tape', '/roms/cartridges/' + file);
+ args.push('/roms/exl100.zip');
+ launch(args);
+ }
+ programSel.onchange = () => bootProgram(programSel.value);
+ function populatePrograms() {
+ let titles;
+ try { titles = JSON.parse(fs().readFile('/roms/cartridges/titles.json', { encoding: 'utf8' })); }
+ catch (e) { return; }
+ 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);
+ }
+ }
+
const Module = window.Module = {
canvas: canvas,
print: (text) => { console.log(text); log(text); },
@@ -269,6 +298,7 @@
if (d) typeSel.value = d.type;
fillDrivers(d ? d.name : '');
statusEl.textContent = drivers.length + ' drivers';
+ populatePrograms();
if (args.length > 0) launch(args);
else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]); // media built in
},