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:
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;