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 a60305b7c88ce95afd964e7caaae6a6556600113
parent 39d83c4c86d93def618d729268185ff3faaafba3
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 11:24:03 -0500

Page : une seule zone « Déposer ou choisir un fichier », type détecté et lancement adapté

Remplace le sélecteur « cartouche / cassette » + champ fichier par une zone de dépôt (clic ou
glisser-déposer, aussi sur l'écran). Le type est détecté par l'extension puis le contenu :
cassette (.k7, .wav ; leader $55) -> ExelBASIC + auto-chargement ; cartouche (.bin/.rom ;
8/16/32 Ko) -> démarrage direct ; image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel,
.bkp webmo = 514 octets d'en-tête + 16 Ko à l'envers) -> volume chargé puis rechargement ;
disquette (.fd, .td0) -> « pas encore prise en charge ». Message d'état explicite. Le bouton
« Démarrer » générique de dsp est masqué. Test : test/upload.mjs (injection CDP d'un fichier).

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

Diffstat:
Atest/upload.mjs | 25+++++++++++++++++++++++++
Mweb/shell.html.in | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 106 insertions(+), 8 deletions(-)

diff --git a/test/upload.mjs b/test/upload.mjs @@ -0,0 +1,25 @@ +// Chargement de fichier unifié : injecte un fichier dans #media (comme un choix ou un dépôt), lit le +// message d'état, capture l'écran. Pour une image Exelmémoire, vérifie le localStorage après rechargement. +// node test/upload.mjs <fichier> [url] [secondes] [capture.png] +import { open, sleep } from './cdp.mjs'; +import { resolve } from 'node:path'; + +const file = resolve(process.argv[2]); +const url = process.argv[3] ?? 'http://127.0.0.1:8195/'; +const secs = Number(process.argv[4] ?? 14); +const shot = process.argv[5] ?? '/tmp/upload.png'; +const page = await open(url, { width: 900, height: 1100 }); +await sleep(6000); +const doc = await page.send('DOM.getDocument', { depth: 1 }); +const { nodeId } = await page.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#media' }); +await page.send('DOM.setFileInputFiles', { files: [file], nodeId }); +await sleep(1500); +const status1 = await page.evaluate("document.getElementById('status').textContent"); +console.log('état immédiat :', status1); +await sleep(secs * 1000); +const status2 = await page.evaluate("document.getElementById('status').textContent"); +const ls = await page.evaluate("(() => { try { return Object.keys(localStorage).filter((k) => k.startsWith('exl100.cram')).map((k) => k + '=' + localStorage.getItem(k).length).join(' '); } catch (e) { return ''; } })()"); +console.log('état final :', status2, '| cram localStorage :', ls || '(aucun)'); +await page.screenshot(shot); +console.log('Capture :', shot); +page.close(); diff --git a/web/shell.html.in b/web/shell.html.in @@ -47,6 +47,9 @@ .toolbar { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center; align-items: center; margin: 14px auto 16px; max-width: 900px; } .toolbar label { color: var(--muted); font-size: .9rem; display: inline-flex; gap: 6px; align-items: center; } + .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); } .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; } @@ -143,16 +146,17 @@ <label class="hidden">ROM <input id="rom" type="file"></label> <button id="pick" type="button" class="pick-btn" title="Parcourir les cartouches et cassettes"> 📂 Choisir un programme…</button> - <label>ou votre fichier - <select id="mediatype"> - <option value="--tape">cartouche / cassette</option> - <option value="--cart">cartouche (--cart)</option> - <option value="--disk">disquette (--disk)</option> - </select> - <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav"> + <label id="drop" class="drop" title="Cassette (.k7, .wav), cartouche (.bin, .rom), image Exelmémoire (.cram, .bkp) ou disquette (.fd, .td0) : le type est détecté et le fichier lancé comme il faut. Vous pouvez aussi le déposer sur l'écran."> + 📄 Déposer ou choisir un fichier + <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav,.cram,.bkp,.fd,.td0" hidden> </label> + <select id="mediatype" hidden> + <option value="--tape">cartouche / cassette</option> + <option value="--cart">cartouche (--cart)</option> + <option value="--disk">disquette (--disk)</option> + </select> <input id="extra" type="hidden" value=""> - <button id="start" title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button> + <button id="start" hidden title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button> <details id="more" class="hidden"><summary>options</summary></details> </div> @@ -562,6 +566,75 @@ setTimeout(() => URL.revokeObjectURL(a.href), 2000); } catch (e) { statusEl.textContent = 'Export .cram impossible : ' + e; } }; + // Fichier de l'utilisateur : une seule zone (clic ou dépôt, aussi sur l'écran), type détecté par + // l'extension puis le contenu, puis lancé comme il faut : cassette = ExelBASIC + auto-chargement, + // cartouche = démarrage direct, image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel, .bkp webmo = + // 514 octets d'en-tête + 16 Ko à l'envers) = volume chargé puis rechargement, disquette = étape à venir. + const CART_SIZES = new Set([8192, 16384, 32256, 32768]); + function detectMedia(name, data) { + const ext = (name.toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || ''; + const n = data.length; + const isWav = n > 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46; + const leader = n > 64 && data.subarray(0, 32).every((b) => b === 0x55); + const bkp = n === 17408 && data[0] === 0xfd; + const cramCtl = n === 16384 && (data[0x3fe1] === 0xaa || (data[0] === 0xaa && data[1] === 0x8c)); + if (ext === 'wav' || isWav) return { kind: 'cassette', why: 'enregistrement .wav' }; + if (ext === 'k7' || leader) return { kind: 'cassette', why: 'cassette .k7' }; + if (ext === 'bkp' || bkp) return { kind: 'exelmemoire', why: 'sauvegarde BKP', bkp: true }; + if (ext === 'cram' || n === 65536 || cramCtl) return { kind: 'exelmemoire', why: n === 65536 ? 'image 64 Ko' : 'image .cram 16 Ko' }; + if (ext === 'fd' || ext === 'td0' || (n > 2 && data[0] === 0x54 && data[1] === 0x44)) return { kind: 'disquette', why: ext === 'td0' ? 'image Teledisk' : 'image de disquette' }; + if (['bin', 'rom', 'cart'].includes(ext) || CART_SIZES.has(n)) return { kind: 'cartouche', why: n + ' octets' }; + return { kind: 'inconnu', why: n + ' octets' }; + } + async function writeMedia(file, data) { + try { fs().mkdir('/uploads'); } catch (e) {} + const path = '/uploads/' + file.name.replace(/[^\w.-]+/g, '_'); + fs().writeFile(path, data); + return path; + } + async function openMedia(file) { + if (!file) return; + const data = new Uint8Array(await file.arrayBuffer()); + const d = detectMedia(file.name, data); + const label = { cassette: 'Cassette', cartouche: 'Cartouche', exelmemoire: 'Exelmémoire', disquette: 'Disquette', inconnu: 'Fichier' }[d.kind]; + if (d.kind === 'disquette') { + statusEl.textContent = label + ' détectée (' + d.why + ') : les disquettes Exeldisk ne sont pas encore prises en charge.'; + return; + } + if (d.kind === 'inconnu') { + statusEl.textContent = 'Type de fichier non reconnu (' + d.why + ') : cassette .k7/.wav, cartouche .bin/.rom, Exelmémoire .cram/.bkp.'; + return; + } + if (d.kind === 'exelmemoire') { + let vol = data; + if (d.bkp) { vol = new Uint8Array(16384); for (let i = 0; i < 16384; i++) vol[i] = data[514 + 16383 - i]; } + else if (data.length === 65536) vol = data.subarray(0x8000, 0xc000); + if (vol.length !== 16384) { statusEl.textContent = 'Image Exelmémoire inattendue (' + data.length + ' octets).'; return; } + let s = ''; + for (let i = 0; i < vol.length; i += 4096) s += String.fromCharCode.apply(null, vol.subarray(i, i + 4096)); + try { localStorage.setItem(cramKey, btoa(s)); if (!onExeltel) localStorage.setItem('exl100.exelmem', '1'); } catch (e) {} + statusEl.textContent = label + ' détectée (' + d.why + ') : volume chargé, redémarrage…'; + location.reload(); + return; + } + const token = ++bootToken; + markActive(''); + const path = await writeMedia(file, data); + const args = ['--game', gameName]; + if (d.kind === 'cassette') args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', path); + else args.push('--tape', path); + args.push(biosZip); + launch(args); + statusEl.textContent = label + ' détectée (' + d.why + ') : ' + (d.kind === 'cassette' ? 'chargement dans ExelBASIC…' : 'démarrage…'); + $('pick').textContent = '📄 ' + file.name; + if (d.kind === 'cassette') autoLoadCassette(token); + } + $('media').onchange = () => openMedia($('media').files[0]); + for (const el of [$('drop'), canvas]) { + el.addEventListener('dragover', (e) => { e.preventDefault(); $('drop').classList.add('over'); }); + el.addEventListener('dragleave', () => $('drop').classList.remove('over')); + el.addEventListener('drop', (e) => { e.preventDefault(); $('drop').classList.remove('over'); openMedia(e.dataTransfer.files[0]); }); + } $('cram-import').onclick = () => $('cram-file').click(); $('cram-file').onchange = async () => { const file = $('cram-file').files[0];