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 75f1acb0e01eaadf6d41cd2aeb0210d68eb1c237
parent f60548f5aeaa7b289dfde2efe2afe83f98afbfa4
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 11:41:47 -0500

Page : explorateur de l'Exelmémoire (volume, fichiers, octets libres, bouton Vider)

Panneau repliable sous la barre, visible quand l'Exelmémoire est branchée : nom du volume,
formaté ou non, octets libres, et la liste des fichiers (nom, extension, taille, longueur
d'enregistrement, drapeaux, commentaire), décodée côté page d'après le format CROS64 :
bloc de contrôle $BFCB-$BFE4 (bas du répertoire $BFDF:$BFE0, zone libre $BFE3:$BFE4,
libres = bas - zone libre - 5) et enregistrements contigus en descendant depuis $BFD0
(type, $FFFC, taille = $10006 - C, longueur d'enregistrement, nom et extension à l'envers,
commentaire, données). Rafraîchi au lancement et à chaque sauvegarde. Test cram-panel.mjs.

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

Diffstat:
Atest/cram-panel.mjs | 25+++++++++++++++++++++++++
Mweb/shell.html.in | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 109 insertions(+), 1 deletion(-)

diff --git a/test/cram-panel.mjs b/test/cram-panel.mjs @@ -0,0 +1,25 @@ +// Explorateur de l'Exelmémoire : charge éventuellement un volume (.cram 16 Ko) dans le localStorage, ouvre +// la page et lit le résumé + les lignes du tableau. +// node test/cram-panel.mjs [volume.cram|-] [url] [capture.png] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const vol = process.argv[2] && process.argv[2] !== '-' ? readFileSync(process.argv[2]) : null; +const url = process.argv[3] ?? 'http://127.0.0.1:8195/?game=exeltel&rom=/roms/exeltel.zip'; +const shot = process.argv[4] ?? '/tmp/cram-panel.png'; +const key = url.includes('exeltel') ? 'exl100.cram.exeltel' : 'exl100.cram.exl100'; +const page = await open(url, { width: 900, height: 1100 }); +await page.evaluate(vol ? `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(Buffer.from(vol).toString('base64'))})` + : `localStorage.removeItem(${JSON.stringify(key)})`); +await page.evaluate('location.reload()'); +await sleep(8000); +const info = await page.evaluate(`(() => { + const p = document.getElementById('cram-panel'); + const rows = [...document.querySelectorAll('#cram-files tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent).join(' | ')); + return { hidden: p.hidden, summary: document.getElementById('cram-summary').textContent, rows, note: document.getElementById('cram-empty').textContent }; +})()`); +console.log(JSON.stringify(info, null, 1)); +await page.evaluate("document.getElementById('cram-panel').open = true"); +await page.screenshot(shot); +console.log('Capture :', shot); +page.close(); diff --git a/web/shell.html.in b/web/shell.html.in @@ -50,6 +50,14 @@ .toolbar .drop { cursor: pointer; border: 1px dashed var(--muted); border-radius: 8px; padding: 6px 12px; color: var(--fg, #ddd); transition: border-color .15s, background .15s; } .toolbar .drop:hover, .toolbar .drop.over { border-color: #6cf; background: rgba(102, 204, 255, .08); } + .cram-panel { max-width: 900px; margin: 0 auto 12px; font-size: .9rem; color: var(--muted); } + .cram-panel summary { cursor: pointer; color: #ddd; } + .cram-body { padding: 8px 4px 0; } + .cram-panel table { border-collapse: collapse; width: 100%; font-family: ui-monospace, monospace; font-size: .85rem; } + .cram-panel th, .cram-panel td { text-align: left; padding: 3px 8px; border-bottom: 1px solid #2a2c33; } + .cram-panel td.num { text-align: right; } + .cram-note { font-size: .82rem; margin: 6px 0; } + .cram-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0 0; } .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; } @@ -160,6 +168,19 @@ <details id="more" class="hidden"><summary>options</summary></details> </div> + <!-- Explorateur de l'Exelmémoire : contenu du volume (16 Ko) lu côté page, mis à jour à chaque sauvegarde. --> + <details id="cram-panel" class="cram-panel" hidden> + <summary id="cram-summary">Exelmémoire</summary> + <div class="cram-body"> + <table id="cram-files"><thead><tr><th>Fichier</th><th>Ext</th><th>Taille</th><th>Enreg.</th><th>Drap.</th><th>Commentaire</th></tr></thead><tbody></tbody></table> + <p id="cram-empty" class="cram-note"></p> + <p class="cram-actions"> + <button id="cram-clear" type="button" class="pick-btn" title="Repartir d'un volume vierge (le contenu actuel est perdu, exportez-le d'abord si besoin)">🗑 Vider</button> + <span class="cram-note">Export / import : boutons « .cram » de la barre. Format : CROS64, enregistrements en descendant depuis $BFD0.</span> + </p> + </div> + </details> + <dialog id="modal" class="modal"> <div class="modal-head"> <strong>Programmes</strong> @@ -332,12 +353,13 @@ if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return; if (!force && !Module.ccall('dsp_web_cram_dirty', 'number', [], [])) return; const b64 = Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], [])); - if (b64) localStorage.setItem(cramKey, b64); + if (b64) { localStorage.setItem(cramKey, b64); cramRefresh(b64); } } catch (e) {} } function cramAutosave() { if (cramTimer) clearInterval(cramTimer); cramTimer = setInterval(() => cramSave(false), 2000); + setTimeout(() => cramRefresh(), 500); } document.addEventListener('visibilitychange', () => { if (document.hidden) cramSave(true); }); window.addEventListener('pagehide', () => cramSave(true)); @@ -666,6 +688,67 @@ el.addEventListener('dragleave', () => $('drop').classList.remove('over')); el.addEventListener('drop', (e) => { e.preventDefault(); $('drop').classList.remove('over'); openMedia(e.dataTransfer.files[0]); }); } + // Explorateur de l'Exelmémoire. Format CROS64 (vérifié sur les volumes webmo et DCExel) : nom du + // volume en $BFD1-$BFDC, $BFE1 = $AA si formaté, bas du répertoire en $BFDF:$BFE0, début de la zone + // libre en $BFE3:$BFE4 (libres = bas - zone libre - 5). Les fichiers sont des enregistrements contigus + // en descendant depuis $BFD0 : type/drapeaux (2), $FFFC (2), C (2, taille = $10006 - C), longueur + // d'enregistrement (2), longueur du nom (1), nom à l'envers, 2 octets, extension à l'envers (3), + // commentaire puis données. + function cramParse(v) { + const at = (a) => v[a - 0x8000]; + const clean = (s) => s.replace(/[^ -~]/g, ' ').trim(); + const name = clean(String.fromCharCode.apply(null, v.subarray(0x3fd1, 0x3fdd))); + const formatted = at(0xbfe1) === 0xaa; + const dirLo = (at(0xbfdf) << 8) | at(0xbfe0), freeLo = (at(0xbfe3) << 8) | at(0xbfe4); + const free = formatted ? Math.max(0, Math.min(16327, dirLo - freeLo - 5)) : 0; + const files = []; + for (let p = 0xbfd0; formatted && p > dirLo && p <= 0xbfd0 && files.length < 64;) { + const b = (o) => at(p - o); + const flags = b(0) | (b(1) << 8), c = b(4) | (b(5) << 8), rclen = b(6) | (b(7) << 8), n = b(8); + const size = 0x10006 - c; + if (size < 12 || size > 16384 || n > 12 || p - size < 0x8000) break; + let nm = '', ext = '', com = ''; + for (let i = 0; i < n; i++) nm += String.fromCharCode(b(9 + i)); + for (let i = 0; i < 3; i++) ext += String.fromCharCode(b(13 + n - i)); // à l'envers en mémoire + for (let i = 0; i < Math.min(24, size - 14 - n); i++) com += String.fromCharCode(b(14 + n + i)); + files.push({ name: clean(nm), ext: clean(ext), size, rclen, flags, comment: clean(com) }); + p -= size; + } + return { name, formatted, free, files }; + } + function cramRefresh(b64) { + const panel = $('cram-panel'); + try { + if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) { panel.hidden = true; return; } + if (!b64) b64 = Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], [])); + const s = atob(b64), v = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) v[i] = s.charCodeAt(i); + const info = cramParse(v); + panel.hidden = false; + $('cram-summary').textContent = 'Exelmémoire — ' + (info.formatted + ? 'volume « ' + (info.name || 'sans nom') + ' », ' + info.files.length + ' fichier' + (info.files.length > 1 ? 's' : '') + + ', ' + info.free.toLocaleString('fr-FR') + ' octets libres' + : 'volume non formaté (CROS : Format / FMT)'); + const tb = $('cram-files').querySelector('tbody'); + tb.innerHTML = ''; + for (const f of info.files) { + const tr = document.createElement('tr'); + for (const [val, cls] of [[f.name, ''], [f.ext, ''], [f.size, 'num'], [f.rclen, 'num'], + [f.flags.toString(16).toUpperCase().padStart(4, '0'), ''], [f.comment, '']]) { + const td = document.createElement('td'); td.textContent = val; if (cls) td.className = cls; tr.appendChild(td); + } + tb.appendChild(tr); + } + $('cram-files').hidden = !info.files.length; + $('cram-empty').textContent = info.formatted ? (info.files.length ? '' : 'Aucun fichier.') : + 'Le volume doit être formaté avant usage (menu CROS64 : Format ; CROS 16K : FMT).'; + } catch (e) { panel.hidden = true; } + } + $('cram-clear').onclick = () => { + if (!confirm('Vider l’Exelmémoire ? Le contenu actuel sera perdu (exportez-le d’abord si besoin).')) return; + try { localStorage.removeItem(cramKey); } catch (e) {} + location.reload(); + }; $('cram-import').onclick = () => $('cram-file').click(); $('cram-file').onchange = async () => { const file = $('cram-file').files[0];