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 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:
M.gitignore | 1+
MMakefile | 12+++++++++---
Atest/programs.mjs | 31+++++++++++++++++++++++++++++++
Atools/get_cartridges.py | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 42++++++++++++++++++++++++++++++++++++------
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 },