commit ae631fe0726872740e0b878586f7d6f3c9dabed6
parent f1331831fe003c6d1297acfc686756f1b6cdfd4d
Author: Jean Schurger <jean@schurger.org>
Date: Thu, 5 Nov 2026 15:44:55 -0500
Modale « Disquette » : contenu de l'image .fd, clic sur un programme = lancement
À chaque disquette choisie (bibliothèque ou dépôt), le répertoire EXELDOS est lu dans la page (entrées de
18 octets, FDR : enregistrements × longueur, AU) et une modale liste les fichiers ; bouton 💽 Disquette
dans la barre. Clic sur un .BAS → redémarrage sur la disquette avec ExelBASIC puis LOADRUN NOM.BAS tapé à
l'invite ; .BIN → EXEC NOM puis « O » (chargeur Jester, Exelmémoire) ; .OBJ → la commande. Vérifié headless :
PING.BAS (37 fichiers listés) et KONG.BIN. Test disk-modal.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
2 files changed, 147 insertions(+), 7 deletions(-)
diff --git a/test/disk-modal.mjs b/test/disk-modal.mjs
@@ -0,0 +1,36 @@
+// Modale de la disquette : clic sur une disquette de la bibliothèque → amorçage + modale listant les fichiers ;
+// clic sur un programme → redémarrage et commande tapée à l'invite EXELDOS. Captures.
+// node test/disk-modal.mjs [bout de titre de la disquette] [bout de nom de fichier] [base-url] [préfixe]
+import { open, sleep } from './cdp.mjs';
+
+const want = (process.argv[2] ?? 'loadrun').toLowerCase();
+const file = (process.argv[3] ?? 'PING').toUpperCase();
+const base = process.argv[4] ?? 'http://127.0.0.1:8196';
+const prefix = process.argv[5] ?? '/tmp/disk-modal';
+const page = await open(base + '/', { width: 900, height: 1100 });
+await page.evaluate('location.reload()');
+await sleep(7000);
+const hit = await page.evaluate(`(() => {
+ const b = [...document.querySelectorAll('.pg')].find((b) => b.dataset.type === 'disk' && b.dataset.search.includes(${JSON.stringify(want)}));
+ if (b) b.click();
+ return b ? b.querySelector('.pg-t').textContent : null;
+})()`);
+console.log('disquette :', hit);
+await sleep(2500);
+const info = await page.evaluate(`({ open: document.getElementById('disk-modal').open, title: document.getElementById('disk-title').textContent,
+ summary: document.getElementById('disk-summary').textContent,
+ rows: [...document.querySelectorAll('#disk-files tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent).join(' | ')) })`);
+console.log('modale :', info.open, info.title, '—', info.summary);
+console.log(info.rows.slice(0, 6).join('\n'), info.rows.length > 6 ? '\n… ' + info.rows.length + ' lignes' : '');
+await page.screenshot(prefix + '-modal.png');
+const clicked = await page.evaluate(`(() => {
+ const tr = [...document.querySelectorAll('#disk-files tbody tr.run')].find((tr) => tr.children[0].textContent.includes(${JSON.stringify(file)}));
+ if (tr) tr.click();
+ return tr ? tr.children[4].textContent : null;
+})()`);
+console.log('fichier cliqué :', clicked);
+await sleep(14000);
+console.log('statut :', await page.evaluate("document.getElementById('status').textContent"));
+await page.screenshot(prefix + '-run.png');
+console.log('Captures :', prefix + '-modal.png', prefix + '-run.png');
+page.close();
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -57,7 +57,10 @@
.cram-sum { color: #ddd; margin: 6px 0; }
#cram-modal table { border-collapse: collapse; width: 100%; font-family: ui-monospace, monospace; font-size: .85rem; }
#cram-modal th, #cram-modal td { text-align: left; padding: 3px 8px; border-bottom: 1px solid #2a2c33; }
- #cram-modal td.num { text-align: right; }
+ .modal td.num { text-align: right; }
+ #disk-files tr.run { cursor: pointer; }
+ #disk-files tr.run:hover td { background: #2b2d35; color: #fff; }
+ #disk-files td.go { color: var(--accent); }
.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 {
@@ -140,6 +143,7 @@
</select>
</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>
<label class="hidden">Machine
<select id="mtype">
<option value="all">All</option><option value="arcade">Arcade</option>
@@ -166,6 +170,20 @@
<!-- Modale Exelmémoire : brancher/débrancher, contenu du volume (lu côté page, à jour à chaque
sauvegarde), import/export .cram, vider. -->
+ <dialog id="disk-modal" class="modal">
+ <div class="modal-head">
+ <strong id="disk-title">Disquette</strong>
+ <button id="disk-modal-close" type="button" class="x" aria-label="Fermer">✕</button>
+ </div>
+ <div class="cram-body">
+ <p id="disk-summary" class="cram-sum"></p>
+ <table id="disk-files"><thead><tr><th>Fichier</th><th>Ext</th><th>Taille</th><th>AU</th><th></th></tr></thead><tbody></tbody></table>
+ <p class="cram-note">Cliquez un programme : la machine redémarre sur la disquette et la commande est tapée à l'invite
+ EXELDOS — <code>LOADRUN NOM.BAS</code> (cartouche ExelBASIC insérée), <code>EXEC NOM</code> (binaire Jester, chargé
+ en Exelmémoire) ou la commande <code>.OBJ</code>. Les autres fichiers (SVC, DSR, SYS, données) ne se lancent pas
+ directement.</p>
+ </div>
+ </dialog>
<dialog id="cram-modal" class="modal">
<div class="modal-head">
<strong>Exelmémoire</strong>
@@ -492,11 +510,15 @@
if (!onExeltel) location.reload();
return;
}
- keepCram = true;
- let basic = false;
- try { basic = diskNeedsBasic(fs().readFile('/roms/disks/' + value.slice(5))); } catch (e) {}
- if (basic) args.push('--tape', '/roms/cartridges/exelbasic.bin'); // programmes .BAS : cartouche insérée
- args.push('--tape', '/roms/disks/' + value.slice(5));
+ const file = value.slice(5);
+ let data, meta = {};
+ try {
+ data = fs().readFile('/roms/disks/' + file);
+ meta = JSON.parse(fs().readFile('/roms/disks/titles.json', { encoding: 'utf8' }))[file] || {};
+ } catch (e) { statusEl.textContent = 'Disquette introuvable : ' + file; return; }
+ setDisk('/roms/disks/' + file, meta.t || file, data);
+ launchDisk(null);
+ return;
} else if (value.startsWith('cram:')) {
// Volume Exelmémoire de la bibliothèque. Programmes EXL 100 (ceux de webmo : code machine qui
// appelle ExelBASIC et le moniteur de l'EXL 100) : volume dans l'Exelmémoire, ExelBASIC, puis
@@ -560,6 +582,7 @@
else if (ch === '(') await sh('Digit5', '(');
else if (ch === ')') await sh('Minus', ')');
else if (ch === ' ') await tap('Space', ' ');
+ else if (ch === '.') await tap('Period', '.');
else if (/[0-9]/.test(ch)) await tap('Digit' + ch, ch);
else await tap('Key' + ch.toUpperCase(), ch.toLowerCase());
}
@@ -760,6 +783,87 @@
} catch (e) {}
return false;
}
+ // Contenu d'une image .fd : même répertoire, plus le FDR de chaque fichier (AU×512 : $FD, drapeaux, nombre
+ // d'enregistrements, longueur d'enregistrement, chaîne d'AU dès +13 : AU hi/lo, nombre).
+ function parseFd(data) {
+ const files = [];
+ let au = 1;
+ for (let n = 0; n < 64 && au > 0 && au < 0x500; n++) {
+ const o = au * 512;
+ if (data[o] !== 0xd0 || data[o + 1] !== 0x0d) break;
+ for (let e = o + 2; e + 18 <= o + 510; e += 18) {
+ if (data[e] === 0 || data[e] === 0xff || data[e] === 0x20) continue;
+ const name = String.fromCharCode.apply(null, data.subarray(e, e + 13)).replace(/[^ -~]/g, ' ').trim();
+ const ext = String.fromCharCode(data[e + 13], data[e + 14], data[e + 15]).replace(/[^ -~]/g, ' ').trim();
+ const fau = (data[e + 16] << 8) | data[e + 17];
+ let size = 0, aus = 0;
+ const f = fau * 512;
+ if (f + 256 <= data.length && data[f] === 0xfd) {
+ size = ((data[f + 2] << 8) | data[f + 3]) * ((data[f + 4] << 8) | data[f + 5]);
+ for (let i = 0; i < 81; i++) { if (!((data[f + 13 + 3 * i] << 8) | data[f + 14 + 3 * i])) break; aus += data[f + 15 + 3 * i]; }
+ }
+ if (name) files.push({ name, ext, au: fau, size, aus: aus + 1 });
+ }
+ au = (data[o + 510] << 8) | data[o + 511];
+ }
+ return files;
+ }
+ // Disquette montée : image, titre, cartouche BASIC nécessaire ; modale de son contenu, lancement d'un fichier.
+ let currentDisk = null;
+ const diskModal = $('disk-modal');
+ function diskCommand(f) {
+ if (f.ext === 'BAS') return { cmd: 'LOADRUN ' + f.name + '.BAS' };
+ if (f.ext === 'BIN') return { cmd: 'EXEC ' + f.name, confirm: 'O' }; // chargeur Jester : « Écraser exelmémoire ? »
+ if (f.ext === 'OBJ') return { cmd: f.name };
+ return null;
+ }
+ function setDisk(path, title, data) {
+ currentDisk = { path, title, data, basic: diskNeedsBasic(data), files: parseFd(data) };
+ $('disk-open').hidden = false;
+ $('disk-title').textContent = 'Disquette « ' + title + ' »';
+ $('disk-summary').textContent = currentDisk.files.length + ' fichier' + (currentDisk.files.length > 1 ? 's' : '') +
+ (currentDisk.basic ? ' — programmes BASIC : cartouche ExelBASIC insérée' : '');
+ const tb = $('disk-files').querySelector('tbody');
+ tb.innerHTML = '';
+ for (const f of currentDisk.files) {
+ const tr = document.createElement('tr');
+ const run = diskCommand(f);
+ for (const [val, cls] of [[f.name, ''], [f.ext, ''], [f.size.toLocaleString('fr-FR'), 'num'], [f.aus, 'num'], [run ? '▶ ' + run.cmd : '', 'go']]) {
+ const td = document.createElement('td'); td.textContent = val; if (cls) td.className = cls; tr.appendChild(td);
+ }
+ if (run) { tr.className = 'run'; tr.title = 'Lancer ' + f.name; tr.onclick = () => launchDisk(f); }
+ tb.appendChild(tr);
+ }
+ }
+ // Redémarre sur la disquette (ExelBASIC si besoin, Exelmémoire gardée) ; avec un fichier, tape sa commande
+ // à l'invite EXELDOS une fois le DOS chargé.
+ function launchDisk(f) {
+ if (!currentDisk) return;
+ const token = ++bootToken;
+ markActive(f ? '' : 'disk:' + currentDisk.path.split('/').pop());
+ if (modal.open) modal.close();
+ if (diskModal.open) diskModal.close();
+ keepCram = true;
+ const args = ['--game', gameName];
+ if (currentDisk.basic) args.push('--tape', '/roms/cartridges/exelbasic.bin');
+ args.push('--tape', currentDisk.path, biosZip);
+ launch(args);
+ if (f) autoDos(token, f, diskCommand(f));
+ else { statusEl.textContent = 'Amorçage sur « ' + currentDisk.title + ' »… le bouton 💽 Disquette liste son contenu.'; setTimeout(() => { if (token === bootToken) diskModal.showModal(); }, 400); }
+ }
+ async function autoDos(token, f, run) {
+ const { sleep, alive, tap, text } = typist(token);
+ statusEl.textContent = 'EXELDOS — lancement de ' + f.name + '.' + f.ext + '…';
+ await sleep(3500); if (!alive()) return; // BOOT V1.5 puis invite [A]
+ canvas.focus();
+ await text(run.cmd);
+ await tap('Enter', 'Enter');
+ if (run.confirm) { await sleep(2500); if (!alive()) return; await text(run.confirm); }
+ if (alive()) statusEl.textContent = 'Si rien ne démarre : à l’invite [A], tapez ' + run.cmd + (run.confirm ? ' puis répondez ' + run.confirm : '') + '.';
+ }
+ $('disk-open').onclick = () => diskModal.showModal();
+ $('disk-modal-close').onclick = () => diskModal.close();
+ diskModal.addEventListener('click', (e) => { if (e.target === diskModal) diskModal.close(); });
async function openMedia(file) {
if (!file) return;
const data = new Uint8Array(await file.arrayBuffer());
@@ -799,7 +903,7 @@
const path = await writeMedia(file, data);
const args = ['--game', gameName];
if (d.kind === 'cassette') args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', path);
- else if (d.kind === 'disquette') { keepCram = true; if (diskNeedsBasic(data)) args.push('--tape', '/roms/cartridges/exelbasic.bin'); args.push('--tape', path); }
+ else if (d.kind === 'disquette') { setDisk(path, file.name, data); launchDisk(null); $('pick').textContent = '📄 ' + file.name; return; }
else args.push('--tape', path);
args.push(biosZip);
launch(args);