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 0d7601e8c787ad09d15381d777e9cd0dd4e8f35b
parent 2790704e4ac055f0b1f0c10cad25efe0a8577157
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 09:05:48 -0500

Exelmémoire : import/export .cram (format DCExel), tests Tape->Cram et import

Boutons « .cram » dans la barre quand l'Exelmémoire est branchée : export du volume
(16 Ko bruts pour l'EXL 100 ; image de 64 Ko de l'espace d'adresses pour l'EXELTEL,
comme DCExel) et import (16 384 ou 65 536 octets, tranche $8000-$BFFF) via le
localStorage puis rechargement. Les images « Exelmémoires 16K et 64K formatées » de
DCExel se chargent telles quelles (volumes « 16K » / « 64K », 16327 octets libres).

Trace du chargeur cassette sous CRAM_DBG : Tape->Cram lit bien la bande (en-têtes
des deux blocs) mais refuse les cassettes ExelBASIC (I/O ERROR 16) : il attend son
propre format d'enregistrements, écrit par Cram->Tape.

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

Diffstat:
Mpatches/exl100.patch | 32+++++++++++++++++++++++++++-----
Atest/cram-import.mjs | 40++++++++++++++++++++++++++++++++++++++++
Atest/cram-tape.mjs | 38++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 40++++++++++++++++++++++++++++++++++++++++
4 files changed, 145 insertions(+), 5 deletions(-)

diff --git a/patches/exl100.patch b/patches/exl100.patch @@ -12,7 +12,7 @@ index a1ed784..3ed811e 100644 "-sMINIFY_HTML=0" "--shell-file=${CMAKE_CURRENT_BINARY_DIR}/shell.html") diff --git a/src/drivers/computers/exelv.cpp b/src/drivers/computers/exelv.cpp -index debcb94..bf243f6 100644 +index debcb94..76c8095 100644 --- a/src/drivers/computers/exelv.cpp +++ b/src/drivers/computers/exelv.cpp @@ -2,6 +2,7 @@ @@ -140,7 +140,29 @@ index debcb94..bf243f6 100644 if (address >= 0xc000 && address <= 0xc7ff) ram_[address - 0xc000] = value; } -@@ -517,9 +613,28 @@ void Exelv::fast_load_hook() { +@@ -503,9 +599,20 @@ void Exelv::fast_load_hook() { + const uint16_t read_byte = exl ? 0xfe82 : 0xfcbd; + const uint16_t pc = maincpu_.pc(); + if (pc == sync_start) { +- if (tape_.seek_sync()) maincpu_.set_pc(after_sync); ++ const bool ok = tape_.seek_sync(); ++#ifdef CRAM_DBG ++ std::fprintf(stderr, "TAPE sync %s\n", ok ? "ok" : "FAIL"); ++#endif ++ if (ok) maincpu_.set_pc(after_sync); + } else if (pc == read_byte) { + const int value = tape_.next_byte(); ++#ifdef CRAM_DBG ++ { ++ static int n = 0; ++ if (value < 0) std::fprintf(stderr, "TAPE fin de bande apres %d octets\n", n); ++ else if (n++ < 48) std::fprintf(stderr, "TAPE byte %02x\n", unsigned(value)); ++ } ++#endif + if (value < 0) return; // end of tape: let the BIOS time out + maincpu_.set_a(uint8_t(value)); + // RETS: the low byte of the return address is on top of the stack. +@@ -517,9 +624,28 @@ void Exelv::fast_load_hook() { void Exelv::on_main_cycles(int cycles) { main_cycles_ += uint64_t(cycles); @@ -170,7 +192,7 @@ index debcb94..bf243f6 100644 const uint32_t cpu_clock = maincpu_.cpu_clock(); audio_accumulator_ += int64_t(cycles) * kSampleRate; while (audio_accumulator_ >= cpu_clock) { -@@ -677,6 +792,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { +@@ -677,6 +803,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { } void Exelv::run_frame() { @@ -193,7 +215,7 @@ index debcb94..bf243f6 100644 const uint32_t cpu_clock = maincpu_.cpu_clock(); const int cycles_per_line = int(double(cpu_clock) / (kFramesPerSecond * kScanlines) + 0.5); for (int line = 0; line < kScanlines; line++) { -@@ -696,6 +827,28 @@ void Exelv::run_frame() { +@@ -696,6 +838,28 @@ void Exelv::run_frame() { tick_keyboard(slice); remain -= slice; } @@ -222,7 +244,7 @@ index debcb94..bf243f6 100644 // Without an I/O CPU ROM, post the "I/O CPU initialized" byte ($08) // the main CPU waits for. The INT1 handler reads it with PA.0 low, // raises PB.1 and waits for PA.0 to follow (tms7020_portb_w). -@@ -709,6 +862,12 @@ void Exelv::run_frame() { +@@ -709,6 +873,12 @@ void Exelv::run_frame() { } } } diff --git a/test/cram-import.mjs b/test/cram-import.mjs @@ -0,0 +1,40 @@ +// Compatibilité .cram DCExel : place les images « formatées » de DCExel dans le localStorage +// (ce que fait l'import de la page), démarre et vérifie que CROS voit le volume (« 16K » / « 64K »). +// node test/cram-import.mjs <base-url> <cram_formatee_16k.cram> <cram_formatee_64k.cram> +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const base = process.argv[2] ?? 'http://127.0.0.1:8195'; +const b64 = (buf) => Buffer.from(buf).toString('base64'); +const img16 = readFileSync(process.argv[3]); +const img64 = readFileSync(process.argv[4]).subarray(0x8000, 0xc000); + +const key = async (page, k, code, vk) => { + await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(60); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(320); +}; + +// EXL 100 : image 16K -> boot CROS, DIR (Entrée) +let page = await open(base + '/?exelmem=1', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exl100', ${JSON.stringify(b64(img16))})`); +await page.evaluate('location.reload()'); +await sleep(9000); +await key(page, 'Enter', 'Enter', 13); await sleep(1500); +await page.screenshot('/tmp/i-exl100-dir.png'); +console.log('exl100 restauration :', page.logs.filter((l) => /restaurée/.test(l)).length); +page.close(); await sleep(800); + +// EXELTEL : image 64K (tranche $8000) -> Périphériques -> Exelmémoire -> en-tête CROS64 +page = await open(base + '/?game=exeltel&rom=/roms/exeltel.zip', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exeltel', ${JSON.stringify(b64(img64))})`); +await page.evaluate('location.reload()'); +await sleep(9000); +for (let i = 0; i < 3; i++) await key(page, 'ArrowDown', 'ArrowDown', 40); +await key(page, 'Enter', 'Enter', 13); await sleep(1200); await key(page, 'Enter', 'Enter', 13); await sleep(3500); +await key(page, 'ArrowDown', 'ArrowDown', 40); await key(page, 'Enter', 'Enter', 13); await sleep(900); +await key(page, 'Enter', 'Enter', 13); await sleep(1800); // Directory -> Screen +await page.screenshot('/tmp/i-exeltel-dir.png'); +console.log('exeltel restauration :', page.logs.filter((l) => /restaurée/.test(l)).length); +page.close(); diff --git a/test/cram-tape.mjs b/test/cram-tape.mjs @@ -0,0 +1,38 @@ +// Tape->Cram sur l'EXELTEL : cassette montée au démarrage, CROS64, Format (volume vierge), +// puis Tape->Cram avec un nom, et Directory. Captures à chaque étape. +// node test/cram-tape.mjs [url] +import { open, sleep } from './cdp.mjs'; + +const url = process.argv[2] ?? + 'http://127.0.0.1:8195/?game=exeltel&rom=/roms/exeltel.zip&args=' + encodeURIComponent('--tape /roms/tapes/air-ball.k7'); +const page = await open(url, { width: 900, height: 1100 }); +await page.evaluate(`localStorage.removeItem('exl100.cram.exeltel')`); +const key = async (k, code, vk, hold = 60, gap = 320) => { + await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(hold); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }); + await sleep(gap); +}; +const down = () => key('ArrowDown', 'ArrowDown', 40); +const up = () => key('ArrowUp', 'ArrowUp', 38); +const enter = () => key('Enter', 'Enter', 13); +const typeSlow = async (text) => { for (const ch of text) await key(ch.toLowerCase(), 'Key' + ch, ch.charCodeAt(0), 80, 400); }; + +await sleep(8000); +await down(); await down(); await down(); await enter(); await sleep(1200); await enter(); await sleep(3500); // CROS64 +for (let i = 0; i < 7; i++) await down(); // Format +await enter(); await sleep(1200); await enter(); await sleep(2500); // nom vide +await page.screenshot('/tmp/t-1-formatted.png'); +await down(); await down(); await down(); // après Format le curseur revient en haut : Tape->Cram = 4e +await enter(); await sleep(1200); +await typeSlow(process.argv[3] ?? 'AIR'); // nom du fichier SUR la bande (air-ball.k7 = « AIR ») +await page.screenshot('/tmp/t-2-name.png'); +await enter(); await sleep(2500); +await page.screenshot('/tmp/t-3-loading.png'); +await sleep(10000); +await page.screenshot('/tmp/t-4-after.png'); +await down(); // curseur revenu en haut : Directory = 2e +await enter(); await sleep(900); await enter(); await sleep(1800); +await page.screenshot('/tmp/t-5-dir.png'); +console.log(page.logs.filter((l) => /TAPE|k7|cassette|TRAP|charg/.test(l)).slice(0, 70).join('\n') || '(pas de log cassette)'); +page.close(); diff --git a/web/shell.html.in b/web/shell.html.in @@ -128,6 +128,11 @@ <label id="exelmem-label" class="machine-sel" title="Extension Exelmémoire : 16 Ko de mémoire sauvegardée et son gestionnaire de fichiers CROS64. Sur l'EXL 100 elle démarre à la place du logo ; sur l'EXELTEL, menu Périphériques → Exelmémoire. Le contenu est conservé dans ce navigateur."> <input type="checkbox" id="exelmem"> Exelmémoire </label> + <span id="cram-io" class="machine-sel" hidden> + <button id="cram-import" type="button" class="pick-btn" title="Charger une image .cram (format DCExel : 16 Ko brut, ou 64 Ko pour l'EXELTEL) dans l'Exelmémoire">📥 .cram</button> + <button id="cram-export" type="button" class="pick-btn" title="Télécharger le contenu de l'Exelmémoire en .cram (format DCExel)">📤 .cram</button> + <input type="file" id="cram-file" accept=".cram,.bin,application/octet-stream" hidden> + </span> <label class="hidden">Machine <select id="mtype"> <option value="all">All</option><option value="arcade">Arcade</option> @@ -532,6 +537,41 @@ u.searchParams.delete('exelmem'); location.href = u.toString(); }; + // Import / export du volume au format .cram de DCExel : 16 Ko bruts ($8000-$BFFF) pour l'EXL 100 ; + // pour l'EXELTEL DCExel écrit une image de 64 Ko de tout l'espace d'adresses (le volume en $8000-$BFFF). + const cramIo = $('cram-io'); + cramIo.hidden = !exelmemEnabled(); + function cramBytes() { + const s = atob(Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], []))); + const a = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); + return a; + } + $('cram-export').onclick = () => { + try { + if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return; + const vol = cramBytes(); + let out = vol; + if (onExeltel) { out = new Uint8Array(65536); out.set(vol, 0x8000); } + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([out], { type: 'application/octet-stream' })); + a.download = 'exelmemoire-' + gameName + '.cram'; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + } catch (e) { statusEl.textContent = 'Export .cram impossible : ' + e; } + }; + $('cram-import').onclick = () => $('cram-file').click(); + $('cram-file').onchange = async () => { + const file = $('cram-file').files[0]; + if (!file) return; + let data = new Uint8Array(await file.arrayBuffer()); + if (data.length === 65536) data = data.subarray(0x8000, 0xc000); + if (data.length !== 16384) { statusEl.textContent = 'Image .cram inattendue (' + data.length + ' octets ; 16384 ou 65536)'; return; } + let s = ''; + for (let i = 0; i < data.length; i += 4096) s += String.fromCharCode.apply(null, data.subarray(i, i + 4096)); + try { localStorage.setItem(cramKey, btoa(s)); } catch (e) {} + location.reload(); // restaurée au lancement, avant que le système ne la lise + }; machineSel.onchange = () => { location.href = machineSel.value === 'exeltel' ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip'