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 2790704e4ac055f0b1f0c10cad25efe0a8577157
parent d015692c45f4c8b34b2920bf9931e486f2ac3846
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 08:44:40 -0500

Exelmémoire sur l'EXL 100 : CROS 16K, interrupteur dans la page, aide

roms/cros64_exl100.rom (exelmemoire_16k.rom) donne à l'EXL 100 la même carte que
l'EXELTEL (OS $D000-$EFFF, CRAM $8000-$BFFF), sans RAM basse. Le BIOS sonde $8000 :
$AA -> lance $8001 ; cette amorce (AA 8C E6 2A, BR @$E62A) est écrite par CROS au
Format, donc le driver la sème sur CRAM vierge, comme la cartouche livrée initialisée.
On démarre sur « CMOS RAM OPERATING SYSTEM V1.5 » (DIR, FMT, MFP, DEL, REN, CPF, BKP,
STR, TST).

Comme ça remplace le logo, la page a une case « Exelmémoire » (EXL 100 seulement,
défaut débranchée, ?exelmem=1|0, préférence mémorisée) : débranchée, le fichier est
retiré du MEMFS avant le démarrage. Volume sauvegardé par machine. Paragraphe d'aide.
Test headless test/cram-exl100.mjs.

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

Diffstat:
Mpatches/exl100.patch | 36+++++++++++++++++++++++++-----------
Aroms/cros64_exl100.rom | 0
Atest/cram-exl100.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 33+++++++++++++++++++++++++++++++++
4 files changed, 104 insertions(+), 11 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..4911df1 100644 +index debcb94..bf243f6 100644 --- a/src/drivers/computers/exelv.cpp +++ b/src/drivers/computers/exelv.cpp @@ -2,6 +2,7 @@ @@ -23,7 +23,7 @@ index debcb94..4911df1 100644 #include <cstring> #include "core/fs_compat.h" #include <fstream> -@@ -178,6 +179,15 @@ bool Exelv::load_bios(const std::string& rom_path, std::string* error) { +@@ -178,6 +179,30 @@ bool Exelv::load_bios(const std::string& rom_path, std::string* error) { if (loader.open(rom_path, &ignored) && loader.load(kExeltelSys, sys, &ignored)) { system_rom_ = std::move(sys); } @@ -36,10 +36,25 @@ index debcb94..4911df1 100644 + cram_pram_.assign(0x0e00, 0); // RAM $0200-$0FFF : pile logicielle, variables, volume A + cram_present_ = true; + } ++ } ++ ++ if (model_ == Model::Exl100) { ++ // Exelmémoire 16K de l'EXL 100 : même carte (OS $D000-$EFFF, CRAM $8000-$BFFF), pas de RAM ++ // basse. Le BIOS ($FF03-$FF17) lit $8000 : $AA -> lance $8001. C'est CROS64 qui écrit cette ++ // amorce au Format ($DEDA-$DEE5, $DF38 : AA, BR @$E62A) ; on la sème sur CRAM vierge, comme ++ // la cartouche livrée initialisée, sinon rien ne permet d'entrer dans CROS64 pour formater. ++ std::vector<uint8_t> os; ++ if (read_plain_file("/roms/cros64_exl100.rom", os) && os.size() == 0x2000) { ++ cram_os_ = std::move(os); ++ cram_ram_.assign(0x4000, 0); ++ const uint8_t stub[4] = {0xaa, 0x8c, 0xe6, 0x2a}; ++ std::copy(stub, stub + 4, cram_ram_.begin()); ++ cram_present_ = true; ++ } } if (main_rom.size() < (model_ == Model::Exl100 ? 0x800u : 0x1000u)) return false; -@@ -377,7 +387,41 @@ uint8_t Exelv::read_main(uint16_t address) { +@@ -377,7 +402,41 @@ uint8_t Exelv::read_main(uint16_t address) { } if (address == 0x0140) return p64_; } @@ -57,7 +72,7 @@ index debcb94..4911df1 100644 + // la vérif système $423A (CMPA @$D002=$55 / @$D003=$AA) qui lit $D000-$EFFF + // directement en code page 2. RAM CRAM à $8000-$BFFF. Pas de $A5 à $C800 + // (sinon la détection boot auto-lance $8001 = RAM vide -> plantage). -+ if (model_ == Model::Exeltel && cram_present_) { ++ if (cram_present_) { + if (address >= 0x8000 && address <= 0xbfff) { +#ifdef CRAM_DBG_READS + std::fprintf(stderr, "MR %04x pc=%04x\n", unsigned(address), unsigned(maincpu_.pc())); @@ -81,7 +96,7 @@ index debcb94..4911df1 100644 if (address >= 0xc000 && address <= 0xc7ff) return ram_[address - 0xc000]; return 0xff; } -@@ -409,6 +453,44 @@ void Exelv::write_main(uint16_t address, uint8_t value) { +@@ -409,6 +468,43 @@ void Exelv::write_main(uint16_t address, uint8_t value) { return; } } @@ -107,8 +122,7 @@ index debcb94..4911df1 100644 + std::fprintf(stderr, "PW %04x=%02x pc=%04x\n", unsigned(address), unsigned(value), + unsigned(maincpu_.pc())); +#endif -+ if (model_ == Model::Exeltel && cram_present_ && -+ address >= 0x8000 && address <= 0xbfff) { ++ if (cram_present_ && address >= 0x8000 && address <= 0xbfff) { +#ifdef CRAM_DBG + std::fprintf(stderr, "MW %04x=%02x pc=%04x\n", unsigned(address), unsigned(value), + unsigned(maincpu_.pc())); @@ -126,7 +140,7 @@ index debcb94..4911df1 100644 if (address >= 0xc000 && address <= 0xc7ff) ram_[address - 0xc000] = value; } -@@ -517,9 +599,28 @@ void Exelv::fast_load_hook() { +@@ -517,9 +613,28 @@ void Exelv::fast_load_hook() { void Exelv::on_main_cycles(int cycles) { main_cycles_ += uint64_t(cycles); @@ -156,7 +170,7 @@ index debcb94..4911df1 100644 const uint32_t cpu_clock = maincpu_.cpu_clock(); audio_accumulator_ += int64_t(cycles) * kSampleRate; while (audio_accumulator_ >= cpu_clock) { -@@ -677,6 +778,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { +@@ -677,6 +792,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { } void Exelv::run_frame() { @@ -179,7 +193,7 @@ index debcb94..4911df1 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 +813,28 @@ void Exelv::run_frame() { +@@ -696,6 +827,28 @@ void Exelv::run_frame() { tick_keyboard(slice); remain -= slice; } @@ -208,7 +222,7 @@ index debcb94..4911df1 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 +848,12 @@ void Exelv::run_frame() { +@@ -709,6 +862,12 @@ void Exelv::run_frame() { } } } diff --git a/roms/cros64_exl100.rom b/roms/cros64_exl100.rom Binary files differ. diff --git a/test/cram-exl100.mjs b/test/cram-exl100.mjs @@ -0,0 +1,46 @@ +// Exelmémoire sur l'EXL 100 (CROS 16K en ligne de commande) : DIR sur CRAM vierge, FMT, DIR, +// sauvegarde localStorage, rechargement de la page, DIR : le volume doit être là. +// node test/cram-exl100.mjs [url] +import { open, sleep } from './cdp.mjs'; + +const url = process.argv[2] ?? 'http://127.0.0.1:8195/?exelmem=1'; +const KEY = 'exl100.cram.exl100'; + +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(260); +}; +const enter = (p) => key(p, 'Enter', 'Enter', 13); +const typeSlow = async (p, text) => { + for (const ch of text.toUpperCase()) await key(p, ch.toLowerCase(), 'Key' + ch, ch.charCodeAt(0)); +}; +const lsLen = (p) => p.evaluate(`(localStorage.getItem(${JSON.stringify(KEY)}) || '').length`); + +let page = await open(url, { width: 900, height: 1100 }); +await page.evaluate(`localStorage.removeItem(${JSON.stringify(KEY)})`); +await sleep(8000); // boot -> CROS 16K +await page.screenshot('/tmp/x-0-cros.png'); +await enter(page); await sleep(1500); // DIR (vierge) +await page.screenshot('/tmp/x-1-dir-vierge.png'); +await typeSlow(page, 'FMT'); await enter(page); await sleep(2500); +await page.screenshot('/tmp/x-2-fmt.png'); +await typeSlow(page, 'Y'); await enter(page); await sleep(2500); // confirmation éventuelle +await page.screenshot('/tmp/x-3-fmt-after.png'); +await typeSlow(page, 'DIR'); await enter(page); +await page.screenshot('/tmp/x-4a-dir-300ms.png'); await sleep(600); +await page.screenshot('/tmp/x-4b-dir-900ms.png'); await sleep(900); +await page.screenshot('/tmp/x-4c-dir-1800ms.png'); +await sleep(4000); +console.log('Phase 1 : localStorage =', await lsLen(page), 'car.'); +page.close(); await sleep(800); + +page = await open(url, { width: 900, height: 1100 }); +console.log('Phase 2 : clé au rechargement =', await lsLen(page), 'car.'); +await sleep(8000); +await page.screenshot('/tmp/x-5-reload.png'); +await enter(page); await sleep(2000); // DIR +await page.screenshot('/tmp/x-6-dir-reload.png'); +console.log('restauration :', page.logs.filter((l) => /Exelmémoire restaurée/.test(l)).join(' | ') || '(aucune)'); +page.close(); diff --git a/web/shell.html.in b/web/shell.html.in @@ -125,6 +125,9 @@ <option value="exeltel">EXELTEL (64 Ko)</option> </select> </label> + <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> <label class="hidden">Machine <select id="mtype"> <option value="all">All</option><option value="arcade">Arcade</option> @@ -194,6 +197,13 @@ <code>.k7</code>, <code>.wav</code>).</p> </div> <div> + <h3>L'Exelmémoire</h3> + <p>L'extension <b>Exelmémoire</b> (16 Ko de mémoire sauvegardée par pile) et son gestionnaire de fichiers + <b>CROS64</b> (Format, Directory, Tape→Cram, Cram→Tape…) sont émulés. Sur l'<b>EXELTEL</b> elle est + toujours branchée : menu <i>Périphériques → Exelmémoire</i>. Sur l'<b>EXL 100</b>, cochez + « Exelmémoire » dans la barre : comme la vraie cartouche, elle démarre alors à la place du logo. Son + contenu est conservé dans ce navigateur (comme le fichier <code>.cram</code> de DCExel) ; <i>Format</i> + l'efface.</p> <h3>Le clavier infrarouge</h3> <p>Le clavier et les manettes de l'EXL 100 étaient sans fil, reliés en infrarouge. Ici c'est votre clavier d'ordinateur, aux positions de MAME. Cliquez d'abord dans l'écran pour lui donner le focus.</p> @@ -270,6 +280,12 @@ outputEl.textContent = ''; statusEl.textContent = 'dsp ' + args.join(' '); canvas.style.display = 'block'; + // Exelmémoire de l'EXL 100 : le driver la branche si /roms/cros64_exl100.rom existe dans le + // MEMFS ; débranchée, on retire le fichier avant de démarrer (une cartouche formatée + // démarrerait CROS64 à la place du logo). L'EXELTEL l'a toujours (menu Périphériques). + if (!onExeltel && !exelmemEnabled()) { + try { fs().unlink('/roms/cros64_exl100.rom'); } catch (e) {} + } try { Module.ccall('dsp_web_start', 'number', ['string'], [args.join('\n')]); } catch (e) { @@ -499,6 +515,23 @@ document.querySelector('header h1').innerHTML = '<span class="logo">EXEL<span>TEL</span></span> dans le navigateur'; } + // Interrupteur Exelmémoire (EXL 100 seulement ; l'EXELTEL l'a toujours) : paramètre ?exelmem=1|0, + // sinon préférence du navigateur, défaut débranchée (le logo reste l'accueil). Changer = recharger. + const exelmemBox = $('exelmem'); + function exelmemEnabled() { + if (onExeltel) return true; + const p = new URLSearchParams(location.search).get('exelmem'); + if (p !== null) return p === '1'; + try { return localStorage.getItem('exl100.exelmem') === '1'; } catch (e) { return false; } + } + exelmemBox.checked = exelmemEnabled(); + exelmemBox.disabled = onExeltel; + exelmemBox.onchange = () => { + try { localStorage.setItem('exl100.exelmem', exelmemBox.checked ? '1' : '0'); } catch (e) {} + const u = new URL(location.href); + u.searchParams.delete('exelmem'); + location.href = u.toString(); + }; machineSel.onchange = () => { location.href = machineSel.value === 'exeltel' ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip'