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 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:
A.gitignore | 9+++++++++
AMakefile | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 35+++++++++++++++++++++++++++++++++++
Adeploy/exl100.schurger.org | 40++++++++++++++++++++++++++++++++++++++++
Aroms/exl100.zip | 0
Atest/browser.mjs | 35+++++++++++++++++++++++++++++++++++
Atest/cdp.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/shell.html.in | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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>