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 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:
Atest/disk-modal.mjs | 36++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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);