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