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 f60548f5aeaa7b289dfde2efe2afe83f98afbfa4
parent a60305b7c88ce95afd964e7caaae6a6556600113
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 11:30:15 -0500

Bibliothèque : catégorie « Exelmémoire » avec les 11 volumes webmo et deux volumes vierges

tools/get_cram.py convertit les sauvegardes .BKP de webmo.fr (514 octets d'en-tête puis
la CRAM à l'envers) en volumes 16 Ko, pose l'amorce d'autostart EXELTEL ($BFE8 = $AA,
BR @$BFE9 vers l'entrée « CALL EXEC » donnée par webmo) et ajoute les volumes formatés
de DCExel. Dans la page : puce « Exelmémoire », groupe « Exelmémoire (volumes CROS64) »,
et un clic charge le volume dans la machine visée (EXELTEL) puis redémarre avec un message
indiquant Périphériques → Exelmémoire / Autostart. Test : test/library-cram.mjs.

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

Diffstat:
M.gitignore | 1+
MMakefile | 1+
Atest/library-cram.mjs | 24++++++++++++++++++++++++
Atools/get_cram.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 35+++++++++++++++++++++++++++++++++--
5 files changed, 170 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -15,3 +15,4 @@ build-dbg/ web-dbg/ web-cram/ roms/dcexel/ +roms/cram/ diff --git a/Makefile b/Makefile @@ -39,6 +39,7 @@ cartridges: roms/cartridges/titles.json roms/cartridges/titles.json: tools/get_cartridges.py upstream python3 -I tools/get_cartridges.py python3 -I tools/get_dcexel.py + python3 -I tools/get_cram.py # Clone d'amont épinglé (idempotent) upstream: diff --git a/test/library-cram.mjs b/test/library-cram.mjs @@ -0,0 +1,24 @@ +// Volume Exelmémoire choisi dans la bibliothèque : clic sur l'entrée, la page bascule sur la machine +// visée (EXELTEL) avec le volume dans le localStorage et un message d'état. Capture après redémarrage. +// node test/library-cram.mjs [bout de titre] [base-url] [capture.png] +import { open, sleep } from './cdp.mjs'; + +const want = (process.argv[2] ?? 'kong').toLowerCase(); +const base = process.argv[3] ?? 'http://127.0.0.1:8195'; +const shot = process.argv[4] ?? '/tmp/lib-cram.png'; +const page = await open(base + '/', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.removeItem('exl100.cram.exeltel')`); +await sleep(7000); +const hit = await page.evaluate(`(() => { + const b = [...document.querySelectorAll('.pg')].find((b) => b.dataset.type === 'cram' && b.dataset.search.includes(${JSON.stringify(want)})); + if (b) b.click(); + return b ? b.querySelector('.pg-t').textContent : null; +})()`); +console.log('entrée cliquée :', hit); +await sleep(12000); +const info = await page.evaluate(`({ href: location.href, status: document.getElementById('status').textContent, + cram: (() => { try { return Object.keys(localStorage).filter((k) => k.startsWith('exl100.cram')).map((k) => k + '=' + localStorage.getItem(k).length).join(' '); } catch (e) { return ''; } })() })`); +console.log('après :', JSON.stringify(info)); +await page.screenshot(shot); +console.log('Capture :', shot); +page.close(); diff --git a/tools/get_cram.py b/tools/get_cram.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Volumes Exelmémoire pour la bibliothèque : les 11 sauvegardes .BKP de webmo.fr (programmes EXELTEL +diffusés par le serveur Exelvision, section « EXELMEMOIRES (CRAM) » de la page restauration-de-medias2) +et les deux volumes formatés vides de DCExel. Chaque volume devient roms/cram/<slug>.cram (16 Ko bruts, +$8000-$BFFF) + roms/cram/titles.json ; non versionnés (roms/cram/ est ignoré), embarqués dans dsp.data. + +Un .BKP = 514 octets d'en-tête puis la CRAM à l'envers ($BFFF d'abord). L'amorce d'autostart EXELTEL +(Périphériques -> Autostart : $BFE8 = $AA puis BR @$BFE9) est posée sur l'entrée indiquée par webmo +(« CALL EXEC(n) » : * 32768, ** 32769, *** 32772, **** 40843, ***** 32784). + +Usage : python3 -I tools/get_cram.py [--dry-run] +""" +import io +import json +import os +import re +import sys +import unicodedata +import urllib.request +import zipfile + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +OUT = os.path.join(ROOT, "roms", "cram") +WEBMO = "https://p7398.webmo.fr/httpdocs/exelvision/website/telechargement/" +DCEXEL = "http://dcexel.free.fr/programmes/exelmemoire/cram_formatee_cram.zip" +UA = {"User-Agent": "Mozilla/5.0 (exl100.schurger.org)"} + +# fichier webmo, titre, éditeur, année, entrée (CALL EXEC) d'après les étoiles de la page webmo +WEBMO_VOLUMES = [ + ("BURGER.BKP", "Burgertime", "JMR Diffusion", "", 32769), + ("CB5QUAD.BKP", "CB5", "Exelvision", "", 40843), + ("DESC.BKP", "Descente - Albertville 92", "Éditions Parallèles", "", 32769), + ("KONG.BKP", "Kong", "JMR Diffusion", "1987", 32769), + ("KAMICAR.BKP", "Kamicar", "Charles Arosio", "", 32769), + ("PINDO.BKP", "Pindo", "Minipuce", "", 32768), + ("PYROX.BKP", "Pyrox", "Éditions Parallèles", "", 32772), + ("QUIZZY.BKP", "Quizzy", "Exelvision", "", 32784), + ("SLALOM.BKP", "Slalom géant - Albertville 92", "Éditions Parallèles", "", 32769), + ("SPHINX.BKP", "Le Sphinx d'Or", "Éditions Parallèles", "", None), + ("AIGLES.BKP", "Les Aigles", "P. Gérard", "", 32772), +] + + +def get(url): + with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r: + return r.read() + + +def slug_of(title): + s = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode().lower() + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") + + +def bkp_to_cram(data): + """En-tête de 514 octets (vérifié par le bloc de contrôle : $BFE1 = $AA, zone libre $8004) puis 16 Ko inversés.""" + for h in (514, *range(0, 1100)): + img = data[h:h + 16384][::-1] + if len(img) == 16384 and img[0x3FE1] == 0xAA and img[0x3FE3:0x3FE5] == b"\x80\x04": + return bytearray(img) + raise ValueError("pas de volume CROS64 reconnu dans le .BKP") + + +def with_autostart(img, entry): + if entry: + img[0x3FE8:0x3FEC] = bytes([0xAA, 0x8C, entry >> 8, entry & 0xFF]) + return img + + +def main(): + dry = "--dry-run" in sys.argv + os.makedirs(OUT, exist_ok=True) + titles = {} + for fn, title, by, year, entry in WEBMO_VOLUMES: + try: + img = with_autostart(bkp_to_cram(get(WEBMO + fn)), entry) + except Exception as e: + print(f" ! {fn}: {e}", file=sys.stderr) + continue + out = slug_of(title) + ".cram" + titles[out] = {"t": title, "by": by, "y": year, "cat": "jeu", "src": "webmo.fr", "machine": "exeltel", + "entry": entry} + print(f" + {out:36s} {title} — {by} (entrée {'$%04X' % entry if entry else '?'})", file=sys.stderr) + if not dry: + open(os.path.join(OUT, out), "wb").write(img) + try: + z = zipfile.ZipFile(io.BytesIO(get(DCEXEL))) + for name in z.namelist(): + data = z.read(name) + if len(data) == 65536: + out, machine, title = "volume-vierge-64k.cram", "exeltel", "Volume vierge formaté (EXELTEL)" + img = data[0x8000:0xC000] + elif len(data) == 16384: + out, machine, title = "volume-vierge-16k.cram", "exl100", "Volume vierge formaté (EXL 100)" + img = data + else: + continue + titles[out] = {"t": title, "by": "DCExel", "y": "1985", "cat": "application", "src": "DCExel", + "machine": machine} + print(f" + {out:36s} {title}", file=sys.stderr) + if not dry: + open(os.path.join(OUT, out), "wb").write(img) + except Exception as e: + print(f" ! DCExel : {e}", file=sys.stderr) + if not dry: + 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]["t"].lower())), f, ensure_ascii=False, indent=1) + print(f"{len(titles)} volumes Exelmémoire {'(simulation)' if dry else 'dans ' + OUT}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/web/shell.html.in b/web/shell.html.in @@ -170,6 +170,7 @@ <button class="chip active" type="button" data-f="all">Tous</button> <button class="chip" type="button" data-f="cart">Cartouches</button> <button class="chip" type="button" data-f="tape">Cassettes</button> + <button class="chip" type="button" data-f="cram">Exelmémoire</button> <button class="chip" type="button" data-f="jeu">Jeux</button> <button class="chip" type="button" data-f="application">Applications</button> <button class="chip" type="button" data-f="educatif">Éducatif</button> @@ -319,6 +320,11 @@ const b64 = localStorage.getItem(cramKey); if (b64 && Module.ccall('dsp_web_cram_load', 'number', ['string'], [b64])) console.log('Exelmémoire restaurée (' + b64.length + ' car.)'); + // Message laissé par la bibliothèque juste avant le redémarrage (volume chargé). + try { + const msg = sessionStorage.getItem('exl100.msg'); + if (msg) { sessionStorage.removeItem('exl100.msg'); setTimeout(() => { statusEl.textContent = msg; }, 300); } + } catch (e) {} } catch (e) {} } function cramSave(force) { @@ -418,6 +424,30 @@ } else if (value.startsWith('tape:')) { args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', '/roms/tapes/' + value.slice(5)); cassette = true; + } else if (value.startsWith('cram:')) { + // Volume Exelmémoire : chargé dans le localStorage de la machine visée (EXELTEL pour les + // programmes webmo), puis démarrage sur cette machine ; le volume est restauré au lancement. + const file = value.slice(5); + let data, meta = {}; + try { + data = fs().readFile('/roms/cram/' + file); + meta = JSON.parse(fs().readFile('/roms/cram/titles.json', { encoding: 'utf8' }))[file] || {}; + } catch (e) { statusEl.textContent = 'Volume introuvable : ' + file; return; } + const machine = meta.machine || 'exeltel'; + let s = ''; + for (let i = 0; i < data.length; i += 4096) s += String.fromCharCode.apply(null, data.subarray(i, i + 4096)); + try { + localStorage.setItem('exl100.cram.' + machine, btoa(s)); + if (machine === 'exl100') localStorage.setItem('exl100.exelmem', '1'); + sessionStorage.setItem('exl100.msg', 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire' + + (machine === 'exeltel' ? ' : Périphériques → Exelmémoire (CROS64) ou → Autostart pour le lancer.' : ' : il démarre sur CROS.')); + } catch (e) {} + if ((machine === 'exeltel') !== onExeltel) { + location.href = machine === 'exeltel' ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip' : location.pathname; + } else { + location.reload(); + } + return; } args.push(biosZip); launch(args); @@ -461,7 +491,7 @@ b.className = 'pg'; b.dataset.value = value; b.dataset.cat = cat || ''; - b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : ''; + b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : value.startsWith('cram:') ? 'cram' : ''; b.dataset.search = (title + ' ' + source).toLowerCase(); const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title; const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source; @@ -484,6 +514,7 @@ onExeltel ? 'terminal télématique, sans programme' : 'aucune cartouche', ''); addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:'); addGroup('Cassettes BASIC', '/roms/tapes/titles.json', 'tape:'); + addGroup('Exelmémoire (volumes CROS64)', '/roms/cram/titles.json', 'cram:'); } // Filtres combinés : texte (titre/source) + une puce (type ou catégorie), et masquage des en-têtes vides. @@ -493,7 +524,7 @@ for (const el of programsEl.querySelectorAll('.pg')) { const okText = q === '' || el.dataset.search.includes(q); const okChip = chip === 'all' || - (chip === 'cart' || chip === 'tape' ? el.dataset.type === chip : el.dataset.cat === chip); + (chip === 'cart' || chip === 'tape' || chip === 'cram' ? el.dataset.type === chip : el.dataset.cat === chip); el.classList.toggle('hide', !(okText && okChip)); } let group = null, any = false;