commit 7871d5a663cf913e757ba6b2ea717beee4edde94
Author: Jean Schurger <jean@schurger.org>
Date: Wed, 4 Nov 2026 14:48:10 -0500
Exelvision EXL 100 (1984) dans le navigateur : dsp-cpp en WebAssembly
Émulateur dsp-cpp de Chusogar (driver exelv d'après MAME, TMS7020 + TMS3556 + TMS5220),
cloné et compilé en WebAssembly par Emscripten, driver exelv seul, ROM système (BIOS)
embarquée. La page démarre sur le logo Exelvision et charge cartouches/cassettes.
Patch Emscripten d'une ligne sur mpeg_audio.cpp (abort sans <cstdlib>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
8 files changed, 552 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,9 @@
+upstream/
+build-web/
+.emcache/
+.serve.pid
+web/dsp.js
+web/dsp.wasm
+web/dsp.data
+web/index.html
+.DS_Store
diff --git a/Makefile b/Makefile
@@ -0,0 +1,71 @@
+# exl100 : Exelvision EXL 100 (1984) dans le navigateur, servi sur https://exl100.schurger.org
+# L'émulateur dsp-cpp de Chusogar (C++17 + SDL2, driver « exelv » d'après MAME) est compilé en
+# WebAssembly par Emscripten, avec la ROM système embarquée ; le reste est du statique.
+HOST = apostrophe
+WWW = /var/www/exl100.schurger.org
+VHOST = exl100.schurger.org
+
+# dsp-cpp est cloné ici à la compilation (pas de licence amont : on ne le versionne pas), épinglé
+# à un commit connu. roms/exl100.zip est la ROM MAME (CRC 049109a3 + 38f6fc7a).
+UPSTREAM_URL = https://github.com/Chusogar/dsp-cpp.git
+UPSTREAM_REV = fa4d1abbdcee2d55b51c7f4f4a469a16f41de5bf
+
+# Emscripten par l'image Docker officielle ; cache des ports (SDL2, zlib) dans .emcache/.
+EMSDK_IMAGE = emscripten/emsdk:6.0.13
+DOCKER = docker run --rm -u $(shell id -u):$(shell id -g) -e HOME=/tmp -e EM_CACHE=/work/.emcache \
+ -v $(CURDIR):/work -w /work $(EMSDK_IMAGE)
+
+# On ne bâtit que le driver « exelv », ROM empaquetée dans dsp.data, démarrage automatique de l'exl100.
+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
+
+all: build
+
+# 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
+
+web/index.html: $(wildcard roms/*) 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.
+ grep -q '<cstdlib>' upstream/src/sound/mpeg_audio.cpp || \
+ sed -i 's|#include <cstring>|#include <cstring>\n#include <cstdlib>|' upstream/src/sound/mpeg_audio.cpp
+ $(DOCKER) emcmake cmake -S /work/upstream -B /work/build-web $(CMFLAGS)
+ $(DOCKER) cmake --build /work/build-web -j --target dsp
+ mkdir -p web
+ cp build-web/dsp.js build-web/dsp.wasm build-web/dsp.data web/
+ cp build-web/dsp.html web/index.html
+ @echo "web/ prêt"
+
+# Démarrage dans un Chromium headless : capture d'écran et présence du canevas
+test: build
+ python3 -m http.server 8191 -d web --bind 127.0.0.1 >/dev/null 2>&1 & echo $$! > .serve.pid; \
+ sleep 1; node test/browser.mjs http://127.0.0.1:8191/ 14 /tmp/exl100.png; r=$$?; kill `cat .serve.pid`; rm -f .serve.pid; exit $$r
+
+# Essai local : http://localhost:8191/
+serve:
+ python3 -m http.server 8191 -d web
+
+# Publication : fichiers statiques copiés sur apostrophe
+deploy: build
+ ssh $(HOST) 'sudo install -d -o jeansch -g jeansch $(WWW)'
+ rsync -rc --delete --exclude '.*' web/ $(HOST):$(WWW)/
+ @echo "https://$(VHOST)/"
+
+# Vhost nginx (une fois) : 8443 derrière sslh, certificat joker, comme les autres sites d'apostrophe
+install-vhost:
+ scp deploy/$(VHOST) $(HOST):/tmp/$(VHOST)
+ ssh $(HOST) 'sudo install -m 644 /tmp/$(VHOST) /etc/nginx/sites-available/$(VHOST) && sudo ln -sf /etc/nginx/sites-available/$(VHOST) /etc/nginx/sites-enabled/$(VHOST) && sudo nginx -t && sudo systemctl reload nginx && rm /tmp/$(VHOST)'
+
+clean:
+ rm -rf build-web web/dsp.js web/dsp.wasm web/dsp.data web/index.html
+
+distclean: clean
+ rm -rf upstream .emcache
diff --git a/README.md b/README.md
@@ -0,0 +1,35 @@
+# exl100 : Exelvision EXL 100 (1984) dans le navigateur
+
+https://exl100.schurger.org — l'ordinateur familial français Exelvision EXL 100 (processeur Texas
+Instruments TMS7020, VDP TMS3556, synthèse vocale TMS5220, clavier infrarouge), émulé dans le
+navigateur par l'émulateur **dsp-cpp** de Chusogar compilé en WebAssembly.
+
+Même recette que [[zx81]] et [[st80]] : fichiers statiques sur apostrophe, publiés par `make deploy`.
+
+## Comment c'est fait
+
+- `upstream/` : clone de [github.com/Chusogar/dsp-cpp](https://github.com/Chusogar/dsp-cpp) (C++17 +
+ SDL2, driver `exelv` d'après MAME `exelv.cpp`), épinglé au commit `fa4d1ab`. **Pas de licence amont :
+ on ne le versionne pas**, le Makefile le clone à la compilation (ignoré par git).
+- `roms/exl100.zip` : ROM système MAME, `exl100in.bin` (TMS7020, CRC `049109a3`) + `exl100_7041.bin`
+ (TMS7041, CRC `38f6fc7a`), prise sur https://mdk.cab/download/split/exl100.zip. Embarquée dans
+ `dsp.data` à la compilation.
+- `web/shell.html.in` : page (shell Emscripten) ; `make build` produit `dsp.js`, `dsp.wasm`, `dsp.data`
+ et `index.html` (depuis `dsp.html`), tous ignorés par git.
+- `deploy/exl100.schurger.org` : vhost nginx d'apostrophe (8443 derrière sslh, joker, `application/wasm`
+ et `application/octet-stream` pour `.data`).
+
+## Commandes
+
+- `make build` : clone dsp-cpp, puis Emscripten par Docker (`emscripten/emsdk:6.0.13`, cache `.emcache/`)
+ construit le seul driver `exelv`, ROM empaquetée, démarrage automatique de l'exl100
+ (`-DDSP_WEB_ARGS="--game exl100 /roms/exl100.zip"`). **Compile du code cloné : à lancer par Jean**
+ (le garde-fou « code externe » bloque l'agent) avec `! make build`.
+- `make test` : démarrage dans Chromium headless, capture `/tmp/exl100.png`.
+- `make serve` : http://localhost:8083/.
+- `make deploy` : rsync vers `apostrophe:/var/www/exl100.schurger.org` ; `make install-vhost` une fois.
+
+## Utilisation
+
+Clavier infrarouge émulé au clavier de l'hôte (positions comme dans MAME). F2 pause, F3 reset, F12 turbo.
+ExelBASIC se charge comme cartouche pour obtenir une invite BASIC. Le son ne démarre qu'après un clic.
diff --git a/deploy/exl100.schurger.org b/deploy/exl100.schurger.org
@@ -0,0 +1,40 @@
+# https://exl100.schurger.org : Exelvision EXL 100 (1984) dans le navigateur (fichiers statiques,
+# dépôt ~/src/perso/exl100 de hotrats, publié par « make deploy »).
+# Émulateur dsp-cpp compilé en WebAssembly (dsp.js/.wasm/.data), ROM système embarquée dans dsp.data.
+# TLS on 8443 behind sslh, like the other vhosts; wildcard certificate.
+server {
+ server_name exl100.schurger.org;
+ listen 8443 ssl;
+ http2 on;
+ ssl_certificate /etc/letsencrypt/live/schurger.org/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/schurger.org/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ root /var/www/exl100.schurger.org;
+ index index.html;
+
+ types {
+ text/html html;
+ text/css css;
+ text/javascript js mjs;
+ application/wasm wasm;
+ application/octet-stream data bin zip;
+ image/svg+xml svg;
+ image/png png;
+ }
+
+ gzip on;
+ gzip_types text/css text/javascript application/wasm application/octet-stream;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+}
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name exl100.schurger.org;
+ return 301 https://$host$request_uri;
+}
diff --git a/roms/exl100.zip b/roms/exl100.zip
Binary files differ.
diff --git a/test/browser.mjs b/test/browser.mjs
@@ -0,0 +1,35 @@
+// Démarrage : ouvre l'URL, attend `secondes`, vérifie que le module WebAssembly a chargé et que le
+// canevas SDL est affiché, puis enregistre une capture d'écran. Échoue si la VM n'a pas démarré.
+// node test/browser.mjs [url] [secondes] [capture.png]
+import { open, sleep } from './cdp.mjs';
+
+const url = process.argv[2] ?? 'http://localhost:8083/';
+const secs = Number(process.argv[3] ?? 14);
+const shot = process.argv[4] ?? '/tmp/exl100.png';
+
+const page = await open(url, { width: 900, height: 1100 });
+await sleep(secs * 1000);
+
+const state = await page.evaluate(`(() => {
+ const c = document.getElementById('canvas');
+ const st = document.getElementById('status');
+ return {
+ hasModule: typeof window.Module === 'object',
+ canvasShown: c ? getComputedStyle(c).display !== 'none' : false,
+ canvasW: c ? c.width : 0,
+ canvasH: c ? c.height : 0,
+ status: st ? st.textContent.trim() : '(pas de #status)',
+ };
+})()`);
+console.log('Module WASM :', state.hasModule);
+console.log('Canevas affiché :', state.canvasShown, `(${state.canvasW}x${state.canvasH})`);
+console.log('Statut :', state.status);
+for (const l of page.logs) console.log(l);
+console.log('Capture :', await page.screenshot(shot));
+page.close();
+
+const errored = page.logs.some((l) => /abort|uncaught|exception|cannot load|BIOS/i.test(l));
+if (!state.hasModule || !state.canvasShown || state.canvasW < 2 || errored) {
+ console.error('ÉCHEC : la machine n’a pas démarré proprement');
+ process.exit(1);
+}
diff --git a/test/cdp.mjs b/test/cdp.mjs
@@ -0,0 +1,81 @@
+// Pilotage d'un Chromium headless par le protocole DevTools, sans dépendance (pas de Playwright sur hotrats).
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+export async function open(url, { port = 9334, width = 1000, height = 1400 } = {}) {
+ const chrome = spawn(process.env.CHROME ?? 'chromium', [
+ '--headless=new', '--no-sandbox', '--hide-scrollbars',
+ '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader',
+ `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, 'about:blank',
+ ], { stdio: 'ignore' });
+
+ let target;
+ for (let i = 0; i < 50 && !target; i++) {
+ await sleep(200);
+ try {
+ const r = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' });
+ target = await r.json();
+ } catch { /* pas encore prêt */ }
+ }
+ if (!target) { chrome.kill(); throw new Error('Chromium ne répond pas'); }
+
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise((r) => ws.addEventListener('open', r));
+ let id = 0;
+ const pending = new Map();
+ const logs = [];
+ ws.addEventListener('message', (ev) => {
+ const m = JSON.parse(ev.data);
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id); }
+ else if (m.method === 'Runtime.consoleAPICalled') logs.push(`[${m.params.type}] ${m.params.args.map((a) => a.value ?? a.description).join(' ')}`);
+ else if (m.method === 'Runtime.exceptionThrown') logs.push(`[exception] ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`);
+ });
+ const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
+ const evaluate = async (expression) => (await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })).result?.value;
+ const screenshot = async (path) => {
+ const png = (await send('Page.captureScreenshot', { format: 'png' })).data;
+ writeFileSync(path, Buffer.from(png, 'base64'));
+ return path;
+ };
+ // Coordonnées de la page pour un point de l'écran Smalltalk (640×480 mis à l'échelle CSS)
+ const canvasPoint = async (x, y) => {
+ const r = await evaluate("(() => { const c = document.getElementById('canvas').getBoundingClientRect(); return [c.left, c.top, c.width, c.height]; })()");
+ const [left, top, w, h] = r;
+ return { x: left + (x * w) / 640, y: top + (y * h) / 480 };
+ };
+ const mouse = async (type, x, y, button = 'none', clickCount = 1) =>
+ send('Input.dispatchMouseEvent', { type, x, y, button, clickCount, buttons: button === 'left' ? 1 : button === 'right' ? 2 : button === 'middle' ? 4 : 0 });
+ const click = async (sx, sy, button = 'left') => {
+ const p = await canvasPoint(sx, sy);
+ await mouse('mouseMoved', p.x, p.y);
+ await sleep(100);
+ await mouse('mousePressed', p.x, p.y, button);
+ await sleep(120);
+ await mouse('mouseReleased', p.x, p.y, button);
+ };
+ // Frappe : chaque touche porte un `code` physique et un keyCode, sinon SDL ne la reconnaît pas
+ // (symbole 0) ; les caractères passent par le keypress (texte), Entrée par le keydown seul.
+ const keyOf = (ch) => {
+ if (ch === '\r') return { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' };
+ if (/[a-z]/i.test(ch)) return { key: ch, code: `Key${ch.toUpperCase()}`, windowsVirtualKeyCode: ch.toUpperCase().charCodeAt(0), text: ch };
+ if (/[0-9]/.test(ch)) return { key: ch, code: `Digit${ch}`, windowsVirtualKeyCode: ch.charCodeAt(0), text: ch };
+ if (ch === ' ') return { key: ' ', code: 'Space', windowsVirtualKeyCode: 32, text: ' ' };
+ return { key: ch, code: 'Unidentified', windowsVirtualKeyCode: 0, text: ch };
+ };
+ const type = async (text) => {
+ for (const ch of text) {
+ const k = keyOf(ch);
+ await send('Input.dispatchKeyEvent', { type: 'keyDown', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode, text: k.text, unmodifiedText: k.text });
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode });
+ await sleep(40);
+ }
+ };
+ const close = () => { ws.close(); chrome.kill(); };
+
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await send('Page.navigate', { url });
+ return { send, evaluate, logs, screenshot, canvasPoint, mouse, click, type, close };
+}
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -0,0 +1,281 @@
+<!doctype html>
+<html lang="fr">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>EXL 100 dans le navigateur</title>
+<meta name="description" content="Émulateur de l'Exelvision EXL 100 (1984) dans le navigateur : processeur Texas Instruments TMS7020, vidéo TMS3556, parole TMS5220, clavier infrarouge. Émulateur dsp-cpp compilé en WebAssembly.">
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%2300e5e5'/%3E%3Cpath d='M8 3l3 3-3 3-3-3z' fill='%23e5004b'/%3E%3Cpath d='M8 6l3 3-3 3-3-3z' fill='%23f5a800'/%3E%3C/svg%3E">
+<style>
+ :root {
+ --bg: #15161a; --fg: #e8e6e0; --muted: #a7a49c;
+ --accent: #e5004b; --cyan: #00e5e5;
+ color-scheme: dark;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; padding: 0; }
+ body {
+ background: var(--bg); color: var(--fg);
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ padding: 0 16px 40px;
+ }
+ a { color: #8fb8ff; }
+ header { max-width: 900px; margin: 24px auto 12px; }
+ h1 { font-size: 1.6rem; font-weight: 600; margin: 0 0 6px; letter-spacing: .01em; }
+ .logo { font-weight: 800; color: #fff; }
+ .logo span { color: var(--cyan); }
+ .sub { color: var(--muted); margin: 0; }
+ main { max-width: 900px; margin: 0 auto; }
+ kbd { font: 0.85em ui-monospace, SFMono-Regular, Menlo, monospace;
+ background: #2b2d35; border: 1px solid #3c3f49; border-bottom-width: 2px;
+ border-radius: 4px; padding: 0 5px; color: #fff; white-space: nowrap; }
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff; }
+
+ /* Écran */
+ .screen { margin: 0 auto; max-width: 900px; }
+ .bezel { background: #050505; border-radius: 14px; padding: 2.5%;
+ box-shadow: 0 10px 40px rgba(0,0,0,.6), inset 0 0 0 2px #222; }
+ #canvas { display: none; margin: 0 auto; background: var(--cyan);
+ image-rendering: pixelated; outline: none;
+ 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); }
+ #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; }
+
+ /* Barre d'outils */
+ .toolbar { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center;
+ align-items: center; margin: 14px auto 16px; max-width: 900px; }
+ .toolbar label { color: var(--muted); font-size: .9rem; display: inline-flex; gap: 6px; align-items: center; }
+ .toolbar select, .toolbar input[type=file], .toolbar button {
+ background: #2b2d35; color: #fff; border: 1px solid #40434e; border-radius: 6px;
+ padding: 7px 10px; font: inherit; font-size: .92rem; cursor: pointer; }
+ .toolbar button:hover { background: #363946; }
+ .toolbar button:active { background: var(--accent); border-color: var(--accent); }
+ .toolbar input[type=file] { max-width: 230px; }
+ .hidden { display: none !important; }
+
+ /* Journal (replié) */
+ #output { max-width: 900px; margin: 8px auto 0; white-space: pre-wrap; color: #7e7b74;
+ font: 11px ui-monospace, monospace; max-height: 16vh; overflow: auto; }
+
+ /* Aide */
+ .guide { max-width: 900px; margin: 28px auto 0; }
+ .guide h2 { font-size: 1.2rem; margin: 0 0 10px; }
+ .guide h3 { font-size: 1rem; margin: 0 0 6px; color: #fff; }
+ .guide p { margin: 0 0 10px; color: var(--fg); font-size: .95rem; }
+ .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 24px; }
+ table { border-collapse: collapse; font-size: .9rem; margin: 6px 0 10px; }
+ td { padding: 3px 10px 3px 0; vertical-align: top; }
+ td + td { color: var(--muted); }
+ footer { max-width: 900px; margin: 36px auto 0; color: var(--muted); font-size: .8rem; }
+</style>
+</head>
+<body>
+<header>
+ <h1><span class="logo">EXL<span>100</span></span> dans le navigateur</h1>
+ <p class="sub">L'Exelvision EXL 100 (1984), l'ordinateur familial français à processeur Texas Instruments
+ TMS7020, clavier infrarouge et synthèse vocale, émulé dans le navigateur.</p>
+</header>
+
+<main>
+ <!-- Barre d'outils. Les contrôles « machine » et « driver » de dsp-cpp sont gardés (le script s'y
+ réfère) mais masqués : la page est fixée sur l'EXL 100. Seul le chargement de média est offert. -->
+ <div id="bar" class="toolbar">
+ <label class="hidden">Machine
+ <select id="mtype">
+ <option value="all">All</option><option value="arcade">Arcade</option>
+ <option value="console">Console</option><option value="computer">Computer</option>
+ </select>
+ </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
+ <select id="mediatype">
+ <option value="--tape">cartouche / cassette</option>
+ <option value="--cart">cartouche (--cart)</option>
+ <option value="--disk">disquette (--disk)</option>
+ </select>
+ <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav">
+ </label>
+ <input id="extra" type="hidden" value="">
+ <button id="start" title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button>
+ <details id="more" class="hidden"><summary>options</summary></details>
+ </div>
+
+ <section class="screen">
+ <div class="bezel"><canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas></div>
+ <div id="status">Chargement…</div>
+ </section>
+
+ <div id="output"></div>
+ <div id="help" hidden></div>
+
+ <section class="guide">
+ <h2>Comment ça marche</h2>
+ <div class="cols">
+ <div>
+ <h3>L'écran de démarrage</h3>
+ <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>
+ </div>
+ <div>
+ <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>
+ <p>Les touches de fonction : <kbd>F2</kbd> pause, <kbd>F3</kbd> réinitialisation, <kbd>F6</kbd> pause
+ cassette, <kbd>F12</kbd> turbo. Dans ExelBASIC, <code>LOAD"1"</code> lit la cassette et
+ <code>SAVE"1"</code> enregistre.</p>
+ </div>
+ <div>
+ <h3>Sous le capot</h3>
+ <p>La machine a deux processeurs Texas Instruments TMS7000 (le TMS7020 principal et un TMS7041 d'entrées
+ /sorties qui dialoguent par une boîte aux lettres), un contrôleur vidéo <b>TMS3556</b> (texte 40×25 ou
+ 320×250 en 8 couleurs) et un synthétiseur vocal <b>TMS5220</b>.</p>
+ <p>L'émulation est le driver <code>exelv</code> de l'émulateur multi-machines
+ <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> (d'après MAME), compilé en WebAssembly avec
+ Emscripten. La ROM système (BIOS, 6 Ko) est embarquée dans la page.</p>
+ </div>
+ </div>
+ </section>
+</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>
+</footer>
+
+<script>
+(() => {
+ // Everything lives in this scope: the Emscripten runtime script defines
+ // globals of its own (run, out, err, ...). Only Module is shared.
+ // Arguments the page starts with when the URL gives none (set by CMake).
+ const DSP_DEFAULT_ARGS = "@DSP_WEB_ARGS@";
+
+ const $ = (id) => document.getElementById(id);
+ const statusEl = $('status'), outputEl = $('output'), canvas = $('canvas');
+ const typeSel = $('mtype'), driverSel = $('driver'), romInput = $('rom');
+ let drivers = []; // [{name, title, type, aliases}] from dsp_web_list()
+ let waitingForRom = false; // a driver was picked and the file dialog is open
+
+ function log(text) { outputEl.textContent += text + '\n'; outputEl.scrollTop = outputEl.scrollHeight; }
+
+ function splitArgs(text) {
+ const out = [];
+ const re = /"([^"]*)"|(\S+)/g;
+ let m;
+ while ((m = re.exec(text)) !== null) out.push(m[1] !== undefined ? m[1] : m[2]);
+ return out;
+ }
+
+ function fs() { return Module.FS || window.FS; }
+ function exists(path) { try { return fs().analyzePath(path).exists; } catch (e) { return false; } }
+
+ async function writeUpload(input, dir) {
+ const file = input.files[0];
+ if (!file) return null;
+ const data = new Uint8Array(await file.arrayBuffer());
+ try { fs().mkdir(dir); } catch (e) {}
+ const path = dir + '/' + file.name;
+ fs().writeFile(path, data);
+ return path;
+ }
+
+ function launch(args) {
+ $('help').hidden = true;
+ outputEl.textContent = '';
+ statusEl.textContent = 'dsp ' + args.join(' ');
+ canvas.style.display = 'block';
+ try {
+ Module.ccall('dsp_web_start', 'number', ['string'], [args.join('\n')]);
+ } catch (e) {
+ if (e !== 'unwind' && !(e && e.name === 'ExitStatus')) throw e;
+ }
+ canvas.focus();
+ }
+
+ function fillDrivers(selected) {
+ const type = typeSel.value;
+ const list = drivers.filter((d) => type === 'all' || d.type === type)
+ .sort((a, b) => a.title.localeCompare(b.title));
+ driverSel.innerHTML = '';
+ const head = document.createElement('option');
+ head.value = '';
+ head.textContent = '— ' + list.length + ' drivers —';
+ driverSel.appendChild(head);
+ for (const d of list) {
+ const opt = document.createElement('option');
+ opt.value = d.name;
+ opt.textContent = d.title + ' (' + d.name + ')';
+ if (d.aliases.length) opt.title = 'also: ' + d.aliases.join(', ');
+ driverSel.appendChild(opt);
+ }
+ driverSel.value = selected && list.some((d) => d.name === selected) ? selected : '';
+ }
+
+ // ROM for a driver: the file box, else a preloaded /roms/NAME.zip (or alias).
+ async function romFor(d) {
+ const uploaded = await writeUpload(romInput, '/uploads');
+ if (uploaded) return uploaded;
+ for (const n of [d.name, ...d.aliases]) {
+ for (const p of ['/roms/' + n + '.zip', '/roms/' + n]) if (exists(p)) return p;
+ }
+ return null;
+ }
+
+ async function startSelected(openDialog) {
+ const d = drivers.find((x) => x.name === driverSel.value);
+ if (!d) { statusEl.textContent = 'Choose a driver.'; return; }
+ const rom = await romFor(d);
+ if (!rom) {
+ statusEl.textContent = 'Choose the ROM for ' + d.title + '.';
+ if (openDialog) { waitingForRom = true; romInput.click(); }
+ return;
+ }
+ const args = ['--game', d.name, rom];
+ const media = await writeUpload($('media'), '/uploads');
+ if (media) args.push($('mediatype').value, media);
+ args.push(...splitArgs($('extra').value));
+ launch(args);
+ }
+
+ typeSel.onchange = () => fillDrivers(driverSel.value);
+ driverSel.onchange = () => { romInput.value = ''; startSelected(true); };
+ romInput.onchange = () => { if (waitingForRom || driverSel.value) { waitingForRom = false; startSelected(false); } };
+ $('start').onclick = () => startSelected(true);
+ canvas.onclick = () => canvas.focus();
+
+ const Module = window.Module = {
+ canvas: canvas,
+ print: (text) => { console.log(text); log(text); },
+ printErr: (text) => { console.warn(text); log(text); },
+ onRuntimeInitialized: () => {
+ drivers = JSON.parse(Module.UTF8ToString(Module.ccall('dsp_web_list', 'number', [], [])));
+ const params = new URLSearchParams(location.search);
+ let args = [];
+ if (params.get('game')) {
+ args = ['--game', params.get('game')];
+ if (params.get('rom')) args.push(params.get('rom'));
+ if (params.get('args')) args.push(...splitArgs(params.get('args')));
+ } else if (DSP_DEFAULT_ARGS.trim() !== '') {
+ args = splitArgs(DSP_DEFAULT_ARGS);
+ }
+ const game = args[args.indexOf('--game') + 1];
+ const d = drivers.find((x) => x.name === game || x.aliases.includes(game));
+ if (d) typeSel.value = d.type;
+ fillDrivers(d ? d.name : '');
+ statusEl.textContent = drivers.length + ' drivers';
+ if (args.length > 0) launch(args);
+ else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]); // media built in
+ },
+ };
+ window.onerror = () => { statusEl.textContent = 'Error, see the log below.'; };
+})();
+</script>
+{{{ SCRIPT }}}
+</body>
+</html>