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 b22fb3ea017057642622fb313e17c63b65f1f268
parent 199733e0fedfdcec0b7ccb47b9ff48bd1b0ae291
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 16:13:12 -0500

Cassettes à plusieurs faces : liaison DCExel, badge, bouton 📼 Face B, changement de cassette à chaud

get_dcexel.py --link : les archives DCExel à plusieurs .k7 (arcade-a/-b, musical-A/-B, solfege1a…, citadelle
intro/oran/irun/kran, pluriel-1/-2…) relient leurs entrées déjà importées (par CRC) via `sides` [{f, l}] dans
roms/tapes/titles.json (19 programmes). Page : badge « 2 faces : Face A / Face B » dans la bibliothèque, après
lancement des boutons 📼 dans la barre pour insérer l'autre face sans redémarrer (export dsp_web_tape_load →
load_media), « ⏏ insérer » sur chaque cassette de la liste pour en changer à chaud. Test tape-sides.mjs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9

Diffstat:
Mpatches/exl100.patch | 16++++++++++++----
Atest/tape-sides.mjs | 27+++++++++++++++++++++++++++
Mtools/get_dcexel.py | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 156 insertions(+), 7 deletions(-)

diff --git a/patches/exl100.patch b/patches/exl100.patch @@ -1,5 +1,5 @@ diff --git a/CMakeLists.txt b/CMakeLists.txt -index a1ed784..3ffea37 100644 +index a1ed784..676588b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -297,6 +297,7 @@ add_library(dsp_lib STATIC @@ -15,7 +15,7 @@ index a1ed784..3ffea37 100644 "-sEXIT_RUNTIME=0" "-sFORCE_FILESYSTEM=1" - "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded']" -+ "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded','_dsp_web_cram_present','_dsp_web_cram_dirty','_dsp_web_cram_save','_dsp_web_cram_load','_dsp_web_disk_present','_dsp_web_disk_dirty','_dsp_web_disk_save','_dsp_web_disk_load']" ++ "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded','_dsp_web_cram_present','_dsp_web_cram_dirty','_dsp_web_cram_save','_dsp_web_cram_load','_dsp_web_disk_present','_dsp_web_disk_dirty','_dsp_web_disk_save','_dsp_web_disk_load','_dsp_web_tape_load']" "-sEXPORTED_RUNTIME_METHODS=['ccall','UTF8ToString','FS']" "-sMINIFY_HTML=0" "--shell-file=${CMAKE_CURRENT_BINARY_DIR}/shell.html") @@ -848,7 +848,7 @@ index 0000000..94dc47c + +} // namespace dsp diff --git a/src/main.cpp b/src/main.cpp -index 6525a1c..7e7058d 100644 +index 6525a1c..ed678ff 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,8 @@ @@ -860,7 +860,7 @@ index 6525a1c..7e7058d 100644 #include "core/embedded_media.h" #include "frontend/sdl_app.h" -@@ -358,6 +360,93 @@ int main(int argc, char** argv) { return run_dsp(argc, argv); } +@@ -358,6 +360,101 @@ int main(int argc, char** argv) { return run_dsp(argc, argv); } // The page starts the embedded media when its URL names no game. extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_has_embedded() { return dsp_embedded_file_count > 0 ? 1 : 0; } @@ -951,6 +951,14 @@ index 6525a1c..7e7058d 100644 + return m && b64 && cram_b64_decode(b64, data) && m->disk_mount(std::move(data)) ? 1 : 0; +} + ++// Cassette changée à chaud (face B, cassette 2…) : la machine continue, le BIOS lira la nouvelle bande ++// au prochain chargement. ++extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_tape_load(const char* path) { ++ dsp::Exelv* m = web_exelv(); ++ std::string error; ++ return m && path && m->load_media(path, &error) ? 1 : 0; ++} ++ extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_start(const char* lines) { static std::vector<std::string> args; static std::vector<char*> argv; diff --git a/test/tape-sides.mjs b/test/tape-sides.mjs @@ -0,0 +1,27 @@ +// Cassettes à plusieurs faces : badge dans la bibliothèque, bouton 📼 « Face B » dans la barre après lancement, +// insertion à chaud (dsp_web_tape_load) avec message d'état. +// node test/tape-sides.mjs [bout de titre] [base-url] +import { open, sleep } from './cdp.mjs'; + +const want = (process.argv[2] ?? 'arcade').toLowerCase(); +const base = process.argv[3] ?? 'http://127.0.0.1:8196'; +const page = await open(base + '/', { width: 900, height: 900 }); +await page.evaluate('location.reload()'); +await sleep(7000); +const row = await page.evaluate(`(() => { + const b = [...document.querySelectorAll('.pg')].find((b) => b.dataset.type === 'tape' && b.dataset.search.includes(${JSON.stringify(want)}) && b.querySelector('.pg-b')); + if (!b) return null; + const badge = b.querySelector('.pg-b').textContent; + b.click(); + return { title: b.querySelector('.pg-t').firstChild.textContent, badge }; +})()`); +console.log('entrée :', JSON.stringify(row)); +await sleep(6000); +const buttons = await page.evaluate("[...document.querySelectorAll('#tape-sides button')].map((b) => b.textContent)"); +console.log('boutons de la barre :', JSON.stringify(buttons)); +await page.evaluate("document.querySelector('#tape-sides button').click()"); +await sleep(500); +const after = await page.evaluate("({ status: document.getElementById('status').textContent, buttons: [...document.querySelectorAll('#tape-sides button')].map((b) => b.textContent) })"); +console.log('après insertion :', JSON.stringify(after)); +console.log(buttons.length && /insérée/.test(after.status) && after.buttons.join() !== buttons.join() ? 'OK' : 'PROBLÈME'); +page.close(); diff --git a/tools/get_dcexel.py b/tools/get_dcexel.py @@ -8,6 +8,7 @@ TOSEC/webmo (même CRC ou même titre normalisé = doublon), dans roms/tapes/ et Usage : python3 -I tools/get_dcexel.py [--dry-run] [--limit N] Les archives téléchargées sont du contenu non sûr : on n'en extrait que des .k7/.rom/.bin, à plat. """ +import glob import io import json import os @@ -90,7 +91,67 @@ def members(data): return out +def side_label(stem, common): + """Libellé d'une face/cassette d'après ce qui distingue le nom du membre (arcade-a -> « Face A », + solfege1b -> « Cassette 1, face B », pluriel-2 -> « Cassette 2 », vol1 -> « Volume 1 », sinon le reste).""" + rest = stem[len(common):].strip("-_ ").lower() + m = re.fullmatch(r"([ab])", rest) + if m: + return "Face " + m.group(1).upper() + m = re.fullmatch(r"(\d+)", rest) + if m: + return "Cassette " + m.group(1) + m = re.fullmatch(r"(\d+)[-_ ]?([ab])", rest) + if m: + return f"Cassette {m.group(1)}, face {m.group(2).upper()}" + m = re.fullmatch(r"vol(?:ume)?[-_ ]?(\d+)", rest) + if m: + return "Volume " + m.group(1) + return rest.replace("-", " ").replace("_", " ").capitalize() if rest else "Principale" + + +def link_sides(tapes): + """Archives DCExel à plusieurs .k7 (faces A/B, cassettes 1/2…) : relie les entrées déjà importées + (retrouvées par CRC) par une liste `sides` [{f, l}] sur chacune, pour que la page offre l'échange de + cassette à chaud. Ne touche à rien d'autre.""" + by_crc = {} + for fn in tapes: + path = os.path.join(TAPES, fn) + if os.path.exists(path): + by_crc[zlib.crc32(open(path, "rb").read()) & 0xffffffff] = fn + linked = 0 + for z in sorted(glob.glob(os.path.join(CACHE, "*k7.zip"))): + try: + items = [(n, d) for n, d in members(open(z, "rb").read()) if n.lower().endswith(".k7")] + except Exception: + continue + if len(items) < 2 or len(items) > 6: + continue # les recueils de dizaines de programmes ne sont pas des faces + stems = [re.sub(r"_exl100$", "", os.path.splitext(n)[0], flags=re.I) for n, _ in items] + if any(re.search(r"\d{4}\.\d{2}", st) for st in stems): + continue # versions datées (démo Jester), pas des faces + common = os.path.commonprefix(stems) + sides = [] + for (n, d), stem in zip(items, stems): + fn = by_crc.get(zlib.crc32(d) & 0xffffffff) + if fn and all(x["f"] != fn for x in sides): + sides.append({"f": fn, "l": side_label(stem, common)}) + if len(sides) < 2: + continue + sides.sort(key=lambda x: (re.sub(r"\d+", lambda m: m.group(0).zfill(3), x["l"]))) + for sd in sides: + tapes[sd["f"]]["sides"] = sides + linked += 1 + print(f"DCExel : {linked} programmes à plusieurs faces/cassettes reliés", file=sys.stderr) + + def main(): + if "--link" in sys.argv: + tapes = json.load(open(os.path.join(TAPES, "titles.json"), encoding="utf-8")) + link_sides(tapes) + with open(os.path.join(TAPES, "titles.json"), "w", encoding="utf-8") as f: + json.dump(tapes, f, ensure_ascii=False, indent=1) + return dry = "--dry-run" in sys.argv limit = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else 10 ** 6 tapes = json.load(open(os.path.join(TAPES, "titles.json"), encoding="utf-8")) @@ -149,6 +210,7 @@ def main(): have_crc.add(crc) have_titles.add(norm(p["title"])) added[kind] += 1 + link_sides(tapes) if not dry: for d, titles in ((TAPES, tapes), (CARTS, carts)): with open(os.path.join(d, "titles.json"), "w", encoding="utf-8") as f: diff --git a/web/shell.html.in b/web/shell.html.in @@ -61,6 +61,11 @@ #disk-files tr.run { cursor: pointer; } #disk-files tr.run:hover td { background: #2b2d35; color: #fff; } #disk-files td.go { color: var(--accent); } + .pg-b { color: var(--accent); font-size: .78rem; margin-left: 8px; } + .pg-ins { margin-left: 8px; background: #2b2d35; color: #ddd; border: 1px solid #40434e; border-radius: 6px; font-size: .75rem; padding: 1px 6px; cursor: pointer; } + .pg-ins:hover { background: #3a3d48; } + #tape-sides { display: inline; } + #tape-sides button { margin-left: 4px; } .cram-note { font-size: .82rem; margin: 6px 0; } .cram-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 10px 0; } .toolbar select, .toolbar input[type=file], .toolbar button { @@ -148,6 +153,7 @@ </label> <button id="cram-open" type="button" class="pick-btn" title="Extension Exelmémoire : 16 Ko de mémoire sauvegardée et son gestionnaire de fichiers CROS64 — brancher/débrancher, contenu du volume, import/export, vider.">💾 Exelmémoire</button> <button id="disk-open" type="button" class="pick-btn" hidden title="Contenu de la disquette montée dans l'Exeldisk : cliquez un programme pour le lancer">💽 Disquette</button> + <span id="tape-sides"></span> <label class="hidden">Machine <select id="mtype"> <option value="all">All</option><option value="arcade">Arcade</option> @@ -281,7 +287,9 @@ <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 + cassette, <kbd>F12</kbd> turbo. Un programme sur deux faces ou plusieurs cassettes le signale dans la + bibliothèque ; quand il réclame la suite, le bouton 📼 de la barre l'insère sans redémarrer (et « ⏏ insérer » + dans la liste change de cassette à chaud). Dans ExelBASIC, <code>LOAD"1"</code> lit la cassette et <code>SAVE"1"</code> enregistre.</p> </div> <div> @@ -507,6 +515,7 @@ } else if (value.startsWith('tape:')) { args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', '/roms/tapes/' + value.slice(5)); cassette = true; + setTape(value.slice(5)); } else if (value.startsWith('disk:')) { // Disquette de la bibliothèque (EXL 100 seulement) : l'Exeldisk est monté avec l'image (le pilote // reconnaît .fd) et la machine amorce dessus (BOOT V1.5 puis EXELDOS). L'Exelmémoire reste branchée, @@ -569,6 +578,36 @@ if (cassette) autoLoadCassette(token); } + // Cassette dans la machine et ses autres faces/cassettes (titles.json `sides`) : boutons 📼 de la barre. + let currentTape = null; + function setTape(file, sides) { + if (!sides) { + let m = {}; + try { m = JSON.parse(fs().readFile('/roms/tapes/titles.json', { encoding: 'utf8' }))[file] || {}; } catch (e) {} + sides = m.sides || []; + } + currentTape = { file, sides }; + const box = $('tape-sides'); + box.innerHTML = ''; + for (const sd of sides) { + if (sd.f === file) continue; + const b = document.createElement('button'); b.type = 'button'; b.className = 'pick-btn'; + b.textContent = '📼 ' + sd.l; + b.title = 'Insérer « ' + sd.l + ' » sans redémarrer, quand le programme la demande'; + b.onclick = () => tapeInsert(sd.f, sd.l); + box.appendChild(b); + } + } + // Change la cassette à chaud : le BIOS lira la nouvelle au prochain chargement (un programme qui réclame + // la face B enchaîne tout seul ; sinon LOAD"1" ⏎ puis Échap dans ExelBASIC). + function tapeInsert(file, label) { + let ok = 0; + try { ok = Module.ccall('dsp_web_tape_load', 'number', ['string'], ['/roms/tapes/' + file]); } catch (e) { ok = 0; } + if (!ok) { statusEl.textContent = 'Impossible d’insérer « ' + label + ' » : lancez d’abord un programme sur cassette.'; return; } + statusEl.textContent = '📼 « ' + label + ' » insérée — reprenez le chargement quand le programme le demande (sinon LOAD"1" ⏎ puis Échap).'; + const sides = currentTape && currentTape.sides.some((x) => x.f === file) ? currentTape.sides : undefined; + setTape(file, sides); + } // Frappe synthétique dans ExelBASIC : événements clavier sur le canevas (SDL écoute #canvas). Le " de // l'EXL 100 = Shift+3, ( = Shift+5, ) = Shift+- ; les modificateurs sont de vraies touches Shift // maintenues. S'annule si un autre programme est lancé entre-temps (jeton). @@ -628,7 +667,7 @@ if (m.y) p.push(m.y); return p.length ? p.join(', ') : 'source inconnue'; } - function addRow(value, title, source, cat) { + function addRow(value, title, source, cat, m) { const b = document.createElement('button'); b.className = 'pg'; b.dataset.value = value; @@ -636,6 +675,19 @@ b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : value.startsWith('cram:') ? 'cram' : value.startsWith('disk:') ? 'disk' : ''; b.dataset.search = (title + ' ' + source).toLowerCase(); const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title; + if (m && m.sides && m.sides.length > 1) { + // Programme sur plusieurs faces/cassettes : on le dit, la suite s'insère avec les boutons 📼 de la barre. + const b = document.createElement('span'); b.className = 'pg-b'; + b.textContent = m.sides.length + ' ' + (m.sides.every((x) => /^Face /.test(x.l)) ? 'faces' : 'cassettes') + ' : ' + m.sides.map((x) => x.l).join(' / '); + b.title = 'Ce programme demande une autre face ou cassette en cours de route : insérez-la alors avec le bouton 📼 de la barre, sans redémarrer.'; + t.appendChild(b); + } + if (value.startsWith('tape:')) { + const ins = document.createElement('button'); ins.type = 'button'; ins.className = 'pg-ins'; ins.textContent = '⏏ insérer'; + ins.title = 'Insérer cette cassette dans la machine qui tourne, sans redémarrer (face B, cassette suivante…)'; + ins.onclick = (e) => { e.stopPropagation(); tapeInsert(value.slice(5), title); modal.close(); }; + t.appendChild(ins); + } const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source; b.append(t, s); b.onclick = () => bootProgram(value); @@ -648,7 +700,7 @@ if (!entries.length) return; const h = document.createElement('div'); h.className = 'pg-group'; h.textContent = label; programsEl.appendChild(h); - for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m), m.cat); + for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m), m.cat, m); } function populatePrograms() { programsEl.innerHTML = '';