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 39d83c4c86d93def618d729268185ff3faaafba3
parent 0d7601e8c787ad09d15381d777e9cd0dd4e8f35b
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 10:38:59 -0500

Bibliothèque : 169 cassettes et 2 cartouches de plus, moissonnées sur DCExel

tools/get_dcexel.py parcourt les 159 pages de programmes de DCExel (Daniel Coulom) et
prend les .k7/.rom propres (<titre>_exl100k7.zip, _exl100rom.zip) qui manquent à TOSEC
et webmo (même CRC ou même titre normalisé = doublon), avec éditeur, année, catégorie et
la source. Lancé après get_cartridges.py par la cible cartridges. La bibliothèque passe
à 297 entrées (Solfège, Cité d'Or, World War 3, Opéral, Monkey Kong, pédagogiciels…),
dsp.data à 7,4 Mo (4,4 Mo gzippés). Crédit DCExel dans la modale et le pied de page.

Les gros programmes BASIC mettent jusqu'à une minute à s'initialiser après le RUN
automatique (« un peu de patience… ») : le message d'attente le dit. Une apostrophe
dans cette phrase (chaîne JS à guillemets simples) a cassé la page en prod quelques
minutes : test/syntax.mjs vérifie désormais les scripts inline dans make test.

Tests de la session Exelmémoire : cram-basic/-exec/-exelquad/-autostart/-dir,
key-probe (( = Shift+5, ) = Shift+-), library-run, tape-timing.

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

Diffstat:
M.gitignore | 1+
MMakefile | 2++
Mpatches/exl100.patch | 27++++++++++++++++++---------
Atest/cram-autostart.mjs | 30++++++++++++++++++++++++++++++
Atest/cram-basic.mjs | 44++++++++++++++++++++++++++++++++++++++++++++
Atest/cram-dir.mjs | 25+++++++++++++++++++++++++
Atest/cram-exec.mjs | 41+++++++++++++++++++++++++++++++++++++++++
Atest/cram-exelquad.mjs | 31+++++++++++++++++++++++++++++++
Atest/key-probe.mjs | 30++++++++++++++++++++++++++++++
Atest/library-run.mjs | 24++++++++++++++++++++++++
Atest/syntax.mjs | 19+++++++++++++++++++
Atest/tape-timing.mjs | 30++++++++++++++++++++++++++++++
Atools/get_dcexel.py | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 10++++++----
14 files changed, 463 insertions(+), 13 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -14,3 +14,4 @@ tools/wav2k7.wasm build-dbg/ web-dbg/ web-cram/ +roms/dcexel/ diff --git a/Makefile b/Makefile @@ -38,6 +38,7 @@ tools/wav2k7.js: tools/wav2k7.cpp upstream/src/drivers/computers/exelv_tape.cpp cartridges: roms/cartridges/titles.json roms/cartridges/titles.json: tools/get_cartridges.py upstream python3 -I tools/get_cartridges.py + python3 -I tools/get_dcexel.py # Clone d'amont épinglé (idempotent) upstream: @@ -63,6 +64,7 @@ web/index.html: $(wildcard roms/*) roms/cartridges/titles.json web/shell.html.in # Démarrage dans un Chromium headless : capture d'écran et présence du canevas test: build + node test/syntax.mjs web/index.html python3 -m http.server 8191 -d web --bind 127.0.0.1 >/dev/null 2>&1 & echo $$! > .serve.pid; \ sleep 1; node test/browser.mjs http://127.0.0.1:8191/ 14 /tmp/exl100.png; r=$$?; kill `cat .serve.pid`; rm -f .serve.pid; exit $$r 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..76c8095 100644 +index debcb94..8891140 100644 --- a/src/drivers/computers/exelv.cpp +++ b/src/drivers/computers/exelv.cpp @@ -2,6 +2,7 @@ @@ -54,7 +54,7 @@ index debcb94..76c8095 100644 } if (main_rom.size() < (model_ == Model::Exl100 ? 0x800u : 0x1000u)) return false; -@@ -377,7 +402,41 @@ uint8_t Exelv::read_main(uint16_t address) { +@@ -377,7 +402,50 @@ uint8_t Exelv::read_main(uint16_t address) { } if (address == 0x0140) return p64_; } @@ -67,6 +67,15 @@ index debcb94..76c8095 100644 + const int pg = exeltel_page(); + if (pg != 2 && pg != 3 && pg != 6) return cram_pram_[address - 0x0200]; + } ++#ifdef CRAM_DBG ++ // Où lit le système (hors CROS64) dans la fenêtre cartouche des pages 0/1 ? (Exelquad, Autostart) ++ if (cram_present_ && address >= 0x0200 && address <= 0x7fff && maincpu_.pc() < 0xd000) { ++ static int n = 0; ++ const int pg = exeltel_page(); ++ if (pg != 2 && pg != 3 && pg != 6 && n++ < 300) ++ std::fprintf(stderr, "LOW %04x page=%d pc=%04x\n", unsigned(address), pg, unsigned(maincpu_.pc())); ++ } ++#endif if (address >= 0x0200 && address <= 0x7fff) return cart_r(uint16_t(address - 0x0200)); + // Exelmémoire : fenêtre haute fixe (non paginée). La présence est vue par + // la vérif système $423A (CMPA @$D002=$55 / @$D003=$AA) qui lit $D000-$EFFF @@ -96,7 +105,7 @@ index debcb94..76c8095 100644 if (address >= 0xc000 && address <= 0xc7ff) return ram_[address - 0xc000]; return 0xff; } -@@ -409,6 +468,43 @@ void Exelv::write_main(uint16_t address, uint8_t value) { +@@ -409,6 +477,43 @@ void Exelv::write_main(uint16_t address, uint8_t value) { return; } } @@ -140,7 +149,7 @@ index debcb94..76c8095 100644 if (address >= 0xc000 && address <= 0xc7ff) ram_[address - 0xc000] = value; } -@@ -503,9 +599,20 @@ void Exelv::fast_load_hook() { +@@ -503,9 +608,20 @@ void Exelv::fast_load_hook() { const uint16_t read_byte = exl ? 0xfe82 : 0xfcbd; const uint16_t pc = maincpu_.pc(); if (pc == sync_start) { @@ -162,7 +171,7 @@ index debcb94..76c8095 100644 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() { +@@ -517,9 +633,28 @@ void Exelv::fast_load_hook() { void Exelv::on_main_cycles(int cycles) { main_cycles_ += uint64_t(cycles); @@ -192,7 +201,7 @@ index debcb94..76c8095 100644 const uint32_t cpu_clock = maincpu_.cpu_clock(); audio_accumulator_ += int64_t(cycles) * kSampleRate; while (audio_accumulator_ >= cpu_clock) { -@@ -677,6 +803,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { +@@ -677,6 +812,22 @@ void Exelv::tick_keyboard(int cpu_cycles) { } void Exelv::run_frame() { @@ -215,7 +224,7 @@ index debcb94..76c8095 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 +838,28 @@ void Exelv::run_frame() { +@@ -696,6 +847,28 @@ void Exelv::run_frame() { tick_keyboard(slice); remain -= slice; } @@ -244,12 +253,12 @@ index debcb94..76c8095 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 +873,12 @@ void Exelv::run_frame() { +@@ -709,6 +882,12 @@ void Exelv::run_frame() { } } } +#ifdef CRAM_PC -+ if (model_ == Model::Exeltel && cram_present_) { ++ if (cram_present_) { + static int fr = 0; + std::fprintf(stderr, "FR%04d PC=%04x\n", fr++, unsigned(maincpu_.pc())); + } diff --git a/test/cram-autostart.mjs b/test/cram-autostart.mjs @@ -0,0 +1,30 @@ +// Volume Exelmémoire sur l'EXELTEL : Périphériques -> Autostart (5e). Captures + trace PC. +// node test/cram-autostart.mjs <volume.cram> [base-url] [prefix] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const img = readFileSync(process.argv[2]); +const base = process.argv[3] ?? 'http://127.0.0.1:8194'; +const prefix = process.argv[4] ?? '/tmp/as'; +const page = await open(base + '/?game=exeltel&rom=/roms/exeltel.zip', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exeltel', ${JSON.stringify(Buffer.from(img).toString('base64'))})`); +await page.evaluate('location.reload()'); +const key = async (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); +}; +const down = () => key('ArrowDown', 'ArrowDown', 40); +const enter = () => key('Enter', 'Enter', 13); +await sleep(9000); +await down(); await down(); await down(); await enter(); await sleep(1200); // Périphériques +await down(); await down(); await down(); await down(); // Autostart (5e) +await page.screenshot(prefix + '-0-hl.png'); +await enter(); await sleep(2500); +await page.screenshot(prefix + '-1-2s.png'); +await sleep(8000); +await page.screenshot(prefix + '-2-10s.png'); +const fr = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, '')).filter((l) => /^FR\d+ PC=/.test(l)); +console.log('frames PC en CRAM :', fr.filter((l) => /PC=[89ab]/.test(l)).length, '/', fr.length, '; derniers :', fr.slice(-10).map((l) => l.split('PC=')[1]).join(' ')); +page.close(); diff --git a/test/cram-basic.mjs b/test/cram-basic.mjs @@ -0,0 +1,44 @@ +// ExelBASIC + Exelmémoire (EXL 100) : les commandes ajoutées par CROS (CROS, LOAD, MERGE, RESTORE, EOF) +// et SAVE vers le volume. Captures : prompt BASIC, après CROS, après Échap, après SAVE, DIR dans CROS. +// node test/cram-basic.mjs [base-url] +import { open, sleep } from './cdp.mjs'; + +const base = process.argv[2] ?? 'http://127.0.0.1:8195'; +const url = base + '/?game=exl100&rom=/roms/exl100.zip&exelmem=1&args=' + encodeURIComponent('--tape /roms/cartridges/exelbasic.bin'); +const page = await open(url, { width: 900, height: 1100 }); +await page.evaluate(`localStorage.removeItem('exl100.cram.exl100')`); +await page.evaluate('location.reload()'); + +const ev = (type, k, code, vk, mods = 0) => + page.send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); +const key = async (k, code, vk, hold = 80, gap = 400) => { await ev('keyDown', k, code, vk); await sleep(hold); await ev('keyUp', k, code, vk); await sleep(gap); }; +const enter = () => key('Enter', 'Enter', 13); +const esc = () => key('Escape', 'Escape', 27); +const quote = async () => { // " de l'EXL 100 = Shift+3, avec une vraie touche Shift maintenue + await ev('keyDown', 'Shift', 'ShiftLeft', 16); await sleep(80); + await ev('keyDown', '"', 'Digit3', 51, 8); await sleep(80); await ev('keyUp', '"', 'Digit3', 51, 8); await sleep(80); + await ev('keyUp', 'Shift', 'ShiftLeft', 16); await sleep(400); +}; +const type = async (s) => { + for (const ch of s) { + if (ch === '"') await quote(); + else if (ch === ' ') await key(' ', 'Space', 32); + else if (ch === '=') await key('=', 'Equal', 187); + else if (/[0-9]/.test(ch)) await key(ch, 'Digit' + ch, ch.charCodeAt(0)); + else await key(ch.toLowerCase(), 'Key' + ch.toUpperCase(), ch.toUpperCase().charCodeAt(0)); + } +}; + +await sleep(9000); +await page.screenshot('/tmp/b-0-basic.png'); +await type('CROS'); await enter(); await sleep(3000); +await page.screenshot('/tmp/b-1-cros.png'); +await esc(); await sleep(2500); +await page.screenshot('/tmp/b-2-back.png'); +await type('10 A=1'); await enter(); await sleep(500); +await type('SAVE "T"'); await enter(); await sleep(5000); +await page.screenshot('/tmp/b-3-save.png'); +await type('CROS'); await enter(); await sleep(3000); +await type('DIR'); await enter(); await sleep(2500); +await page.screenshot('/tmp/b-4-dir.png'); +page.close(); diff --git a/test/cram-dir.mjs b/test/cram-dir.mjs @@ -0,0 +1,25 @@ +// Directory CROS64 d'un volume .cram (16 Ko) sur l'EXELTEL : Périphériques -> Exelmémoire -> Directory -> Screen. +// node test/cram-dir.mjs <volume.cram> [base-url] [capture.png] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const img = readFileSync(process.argv[2]); +const base = process.argv[3] ?? 'http://127.0.0.1:8195'; +const shot = process.argv[4] ?? '/tmp/dir.png'; +const page = await open(base + '/?game=exeltel&rom=/roms/exeltel.zip', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exeltel', ${JSON.stringify(Buffer.from(img).toString('base64'))})`); +await page.evaluate('location.reload()'); +const key = async (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); +}; +const down = () => key('ArrowDown', 'ArrowDown', 40); +const enter = () => key('Enter', 'Enter', 13); +await sleep(9000); +await down(); await down(); await down(); await enter(); await sleep(1200); await enter(); await sleep(3500); +await down(); await enter(); await sleep(900); await enter(); await sleep(2000); +await page.screenshot(shot); +console.log('Capture :', shot); +page.close(); diff --git a/test/cram-exec.mjs b/test/cram-exec.mjs @@ -0,0 +1,41 @@ +// Programme Exelmémoire sur l'EXL 100 (chemin webmo) : volume .cram chargé, cartouche ExelBASIC, +// puis « CALL EXEC(n) » tapé dans BASIC. Captures à 3 s et 10 s. +// node test/cram-exec.mjs <volume.cram> <n> [base-url] [prefix] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const img = readFileSync(process.argv[2]); +const n = process.argv[3] ?? '32769'; +const base = process.argv[4] ?? 'http://127.0.0.1:8195'; +const prefix = process.argv[5] ?? '/tmp/x'; +const url = base + '/?game=exl100&rom=/roms/exl100.zip&exelmem=1&args=' + encodeURIComponent('--tape /roms/cartridges/exelbasic.bin'); +const page = await open(url, { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exl100', ${JSON.stringify(Buffer.from(img).toString('base64'))})`); +await page.evaluate('location.reload()'); +const ev = (type, k, code, vk, mods = 0) => + page.send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); +const key = async (k, code, vk, hold = 80, gap = 400) => { await ev('keyDown', k, code, vk); await sleep(hold); await ev('keyUp', k, code, vk); await sleep(gap); }; +const shifted = async (k, code, vk) => { + await ev('keyDown', 'Shift', 'ShiftLeft', 16); await sleep(80); + await ev('keyDown', k, code, vk, 8); await sleep(80); await ev('keyUp', k, code, vk, 8); await sleep(80); + await ev('keyUp', 'Shift', 'ShiftLeft', 16); await sleep(400); +}; +const type = async (s) => { + for (const ch of s) { + if (ch === '(') await shifted('%', 'Digit5', 53); // clavier EXL 100 : ( = Shift+5 + else if (ch === ')') await shifted('_', 'Minus', 189); // ) = Shift+- + else if (ch === ' ') await key(' ', 'Space', 32); + else if (/[0-9]/.test(ch)) await key(ch, 'Digit' + ch, ch.charCodeAt(0)); + else await key(ch.toLowerCase(), 'Key' + ch.toUpperCase(), ch.toUpperCase().charCodeAt(0)); + } +}; +await sleep(9000); +await type('CALL EXEC(' + n + ')'); +await page.screenshot(prefix + '-0-typed.png'); +await key('Enter', 'Enter', 13); await sleep(3000); +await page.screenshot(prefix + '-1-3s.png'); +await sleep(7000); +await page.screenshot(prefix + '-2-10s.png'); +const fr = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, '')).filter((l) => /^FR\d+ PC=/.test(l)); +if (fr.length) console.log('frames PC en CRAM :', fr.filter((l) => /PC=[89ab]/.test(l)).length, '/', fr.length, '; derniers :', fr.slice(-8).map((l) => l.split('PC=')[1]).join(' ')); +page.close(); diff --git a/test/cram-exelquad.mjs b/test/cram-exelquad.mjs @@ -0,0 +1,31 @@ +// Programme Exelmémoire sur l'EXELTEL : charge un volume .cram (16 Ko) dans le localStorage, +// démarre, va dans le menu « Exelquad » (7e) et capture ce qui se passe. +// node test/cram-exelquad.mjs <volume.cram> [base-url] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const img = readFileSync(process.argv[2]); +const base = process.argv[3] ?? 'http://127.0.0.1:8195'; +const page = await open(base + '/?game=exeltel&rom=/roms/exeltel.zip', { width: 900, height: 1100 }); +await page.evaluate(`localStorage.setItem('exl100.cram.exeltel', ${JSON.stringify(Buffer.from(img).toString('base64'))})`); +await page.evaluate('location.reload()'); +const key = async (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); +}; +await sleep(9000); +for (let i = 0; i < 6; i++) await key('ArrowDown', 'ArrowDown', 40); // Exelquad +await page.screenshot('/tmp/q-0-menu.png'); +await key('Enter', 'Enter', 13); await sleep(1200); +await page.screenshot('/tmp/q-1-exelquad.png'); +await sleep(5000); +await page.screenshot('/tmp/q-2-6s.png'); +await sleep(8000); +await page.screenshot('/tmp/q-3-14s.png'); +console.log('restauration :', page.logs.filter((l) => /restaurée/.test(l)).length); +const fr = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, '')).filter((l) => /^FR\d+ PC=/.test(l)); +const inCram = fr.filter((l) => /PC=[89ab]/.test(l)).length; +console.log('frames avec PC en $8000-$BFFF (CRAM) :', inCram, '/', fr.length, '; derniers :', fr.slice(-12).map((l) => l.split('PC=')[1]).join(' ')); +page.close(); diff --git a/test/key-probe.mjs b/test/key-probe.mjs @@ -0,0 +1,30 @@ +// Quelle combinaison donne ( et ) sur le clavier EXL 100 émulé ? Tape chaque candidat sur sa +// propre ligne BASIC (puis Entrée) et capture l'écho. +// node test/key-probe.mjs [base-url] +import { open, sleep } from './cdp.mjs'; +const base = process.argv[2] ?? 'http://127.0.0.1:8195'; +const url = base + '/?game=exl100&rom=/roms/exl100.zip&exelmem=0&args=' + encodeURIComponent('--tape /roms/cartridges/exelbasic.bin'); +const page = await open(url, { width: 900, height: 1100 }); +const ev = (type, k, code, vk, mods = 0) => + page.send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); +const key = async (k, code, vk, hold = 80, gap = 350) => { await ev('keyDown', k, code, vk); await sleep(hold); await ev('keyUp', k, code, vk); await sleep(gap); }; +const shifted = async (k, code, vk) => { + await ev('keyDown', 'Shift', 'ShiftLeft', 16); await sleep(80); + await ev('keyDown', k, code, vk, 8); await sleep(80); await ev('keyUp', k, code, vk, 8); await sleep(80); + await ev('keyUp', 'Shift', 'ShiftLeft', 16); await sleep(350); +}; +const cands = [ + ['S5', () => shifted('%', 'Digit5', 53)], ['S8', () => shifted('*', 'Digit8', 56)], ['S9', () => shifted('(', 'Digit9', 57)], + ['S0', () => shifted(')', 'Digit0', 48)], ['S1', () => shifted('!', 'Digit1', 49)], ['S2', () => shifted('@', 'Digit2', 50)], + ['S4', () => shifted('$', 'Digit4', 52)], ['S6', () => shifted('^', 'Digit6', 54)], ['S7', () => shifted('&', 'Digit7', 55)], +]; +await sleep(9000); +for (const [label, fn] of cands) { + for (const ch of label) { if (ch === 'S') await key('s', 'KeyS', 83); else if (/[0-9]/.test(ch)) await key(ch, 'Digit' + ch, ch.charCodeAt(0)); else await key('x', 'KeyX', 88); } + await key(' ', 'Space', 32); + await fn(); + await key('Enter', 'Enter', 13); await sleep(250); +} +await page.screenshot('/tmp/keys.png'); +console.log('Capture : /tmp/keys.png'); +page.close(); diff --git a/test/library-run.mjs b/test/library-run.mjs @@ -0,0 +1,24 @@ +// Lance un programme de la bibliothèque par son titre (clic sur l'entrée .pg, comme un visiteur), +// attend l'auto-chargement (ExelBASIC + cassette, LOAD"1"/RUN) et capture l'écran. +// node test/library-run.mjs <bout de titre> [base-url] [capture.png] [secondes] +import { open, sleep } from './cdp.mjs'; + +const want = (process.argv[2] ?? 'world war 3').toLowerCase(); +const base = process.argv[3] ?? 'http://127.0.0.1:8195'; +const shot = process.argv[4] ?? '/tmp/lib-run.png'; +const secs = Number(process.argv[5] ?? 18); +const page = await open(base + '/', { width: 900, height: 1100 }); +await sleep(7000); +const info = await page.evaluate(`(() => { + const all = [...document.querySelectorAll('.pg')]; + const hit = all.find((b) => b.dataset.search.includes(${JSON.stringify(want)})); + if (hit) hit.click(); + return { total: all.length, hit: hit ? hit.querySelector('.pg-t').textContent + ' — ' + hit.querySelector('.pg-s').textContent : null }; +})()`); +console.log('entrées :', info.total, '; lancé :', info.hit); +await sleep(secs * 1000); +await page.screenshot(shot); +console.log('Capture :', shot); +const tape = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, '')).filter((l) => /^TAPE/.test(l)); +if (tape.length) console.log('trace chargeur :', tape.length, 'lignes ;', tape.slice(0, 6).join(' | '), '…', tape.slice(-3).join(' | ')); +page.close(); diff --git a/test/syntax.mjs b/test/syntax.mjs @@ -0,0 +1,19 @@ +// Garde-fou avant déploiement : les scripts inline de web/index.html doivent être du JavaScript valide +// (une apostrophe dans une chaîne à guillemets simples a déjà cassé la page en prod). +// node test/syntax.mjs [web/index.html] +import { readFileSync } from 'node:fs'; + +const path = process.argv[2] ?? 'web/index.html'; +const html = readFileSync(path, 'utf8'); +const scripts = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]); +let bad = 0; +for (const [i, body] of scripts.entries()) { + try { + new Function(body); + } catch (e) { + bad++; + console.error(`script inline n°${i + 1} : ${e.message}`); + } +} +console.log(`${scripts.length} scripts inline, ${bad} en erreur`); +process.exit(bad ? 1 : 0); diff --git a/test/tape-timing.mjs b/test/tape-timing.mjs @@ -0,0 +1,30 @@ +// Sépare LOAD et RUN d'une cassette pour voir ce qui prend du temps. +// node test/tape-timing.mjs <fichier.k7> [base-url] [prefix] +import { open, sleep } from './cdp.mjs'; + +const k7 = process.argv[2] ?? 'world-war-3.k7'; +const base = process.argv[3] ?? 'http://127.0.0.1:8195'; +const prefix = process.argv[4] ?? '/tmp/tt'; +const url = base + '/?game=exl100&rom=/roms/exl100.zip&exelmem=0&args=' + + encodeURIComponent('--tape /roms/cartridges/exelbasic.bin --tape /roms/tapes/' + k7); +const page = await open(url, { width: 900, height: 1100 }); +const ev = (type, k, code, vk, mods = 0) => + page.send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, modifiers: mods }); +const key = async (k, code, vk, hold = 80, gap = 400) => { await ev('keyDown', k, code, vk); await sleep(hold); await ev('keyUp', k, code, vk); await sleep(gap); }; +const quote = async () => { + await ev('keyDown', 'Shift', 'ShiftLeft', 16); await sleep(80); + await ev('keyDown', '"', 'Digit3', 51, 8); await sleep(80); await ev('keyUp', '"', 'Digit3', 51, 8); await sleep(80); + await ev('keyUp', 'Shift', 'ShiftLeft', 16); await sleep(400); +}; +const type = async (s) => { for (const ch of s) { if (ch === '"') await quote(); else if (/[0-9]/.test(ch)) await key(ch, 'Digit' + ch, ch.charCodeAt(0)); else await key(ch.toLowerCase(), 'Key' + ch, ch.charCodeAt(0)); } }; +await sleep(9000); +const t0 = Date.now(); +await type('LOAD"1"'); await key('Enter', 'Enter', 13); +await sleep(3000); await page.screenshot(prefix + '-1-load-3s.png'); +await key('Escape', 'Escape', 27); await sleep(500); +await type('RUN'); await key('Enter', 'Enter', 13); +await sleep(2000); await page.screenshot(prefix + '-2-run-2s.png'); +await sleep(18000); await page.screenshot(prefix + '-3-run-20s.png'); +await sleep(30000); await page.screenshot(prefix + '-4-run-50s.png'); +console.log('captures', prefix + '-*.png ; durée', Math.round((Date.now() - t0) / 1000), 's'); +page.close(); diff --git a/tools/get_dcexel.py b/tools/get_dcexel.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Complète la bibliothèque avec les programmes de DCExel (http://dcexel.free.fr, Daniel Coulom) : +159 pages de programmes, chacune avec des fichiers propres <slug>_exl100k7.zip (cassette .k7 +transférée numériquement) ou <slug>_exl100rom.zip (cartouche). On ne prend que ce qui manque à +TOSEC/webmo (même CRC ou même titre normalisé = doublon), dans roms/tapes/ et roms/cartridges/ +(non versionnés), et on complète les deux titles.json avec la source « DCExel ». + +Usage : python3 -I tools/get_dcexel.py [--dry-run] [--limit N] +Les archives téléchargées sont du contenu non sûr : on n'en extrait que des .k7/.rom/.bin, à plat. +""" +import io +import json +import os +import re +import sys +import unicodedata +import urllib.request +import zipfile +import zlib +from html import unescape + +BASE = "http://dcexel.free.fr/programmes/_html/" +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +TAPES = os.path.join(ROOT, "roms", "tapes") +CARTS = os.path.join(ROOT, "roms", "cartridges") +CACHE = os.path.join(ROOT, "roms", "dcexel") # téléchargements bruts (ignoré par git) +UA = {"User-Agent": "Mozilla/5.0 (exl100.schurger.org ; moisson DCExel)"} +CART_SIZES = {8192, 16384, 32256, 32768} + + +def get(url, binary=False): + with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r: + data = r.read() + return data if binary else data.decode("latin-1") + + +def text(html): + return unescape(" ".join(re.sub(r"<[^>]+>", " ", html).split())) + + +def norm(s): + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower() + return re.sub(r"[^a-z0-9]+", "", s) + + +def slug_of(title): + s = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode().lower() + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s)).strip("-") or "sans-titre" + + +CATS = {"jeu": "jeu", "educatif": "educatif", "utilitaire": "application", "systeme": "application", + "autres": "application", "demonstration": "demo"} + + +def program_pages(): + index = get(BASE + "index.html") + pages = set() + for cat in re.findall(r'href="(categorie_[a-z_]+\.html)"', index): + pages.update(re.findall(r'href="(\.\./[a-z0-9_-]+/index\.html)"', get(BASE + cat))) + return sorted(pages) + + +def parse_program(rel): + html = get(BASE + rel) + title = text(re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S).group(1)) if re.search(r"<h1", html) else \ + text(re.search(r"<title>(.*?)</title>", html, re.S).group(1)) + t = text(html) + m = {k: (re.search(k + r"\s*:\s*(.*?)\s*(?:•|•|Ann|Cat|Auteur|©|$)", t) or [None, ""])[1] + for k in ("Editeur", "Année", "Catégorie")} + files = sorted(set(f for f in re.findall(r'href="([^"]+\.zip)"', html))) + year = m["Année"].strip() + if not re.match(r"\d{4}$", year): + year = "" + by = m["Editeur"].strip() + if norm(by) == "inconnu": + by = "" + return {"rel": rel, "title": title.strip(), "by": by, "y": year, + "cat": CATS.get(norm(m["Catégorie"]), "application"), "files": files} + + +def members(data): + """Fichiers utiles (.k7/.rom/.bin) d'une archive zip, nom à plat, sans suivre de chemin.""" + out = [] + with zipfile.ZipFile(io.BytesIO(data)) as z: + for info in z.infolist(): + name = os.path.basename(info.filename) + if info.is_dir() or not name.lower().endswith((".k7", ".rom", ".bin")) or info.file_size > 4 << 20: + continue + out.append((name, z.read(info))) + return out + + +def main(): + dry = "--dry-run" in sys.argv + limit = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else 10 ** 6 + tapes = json.load(open(os.path.join(TAPES, "titles.json"), encoding="utf-8")) + carts = json.load(open(os.path.join(CARTS, "titles.json"), encoding="utf-8")) + have_titles = {norm(v["t"]) for v in list(tapes.values()) + list(carts.values())} + have_crc = set() + for d, names in ((TAPES, tapes), (CARTS, carts)): + for fn in names: + p = os.path.join(d, fn) + if os.path.exists(p): + have_crc.add(zlib.crc32(open(p, "rb").read()) & 0xffffffff) + os.makedirs(CACHE, exist_ok=True) + pages = program_pages() + print(f"{len(pages)} programmes sur DCExel", file=sys.stderr) + added = {"k7": 0, "rom": 0} + for rel in pages[:limit]: + try: + p = parse_program(rel) + except Exception as e: # page cassée : on continue + print(f" ! {rel}: {e}", file=sys.stderr) + continue + if norm(p["title"]) in have_titles: + continue + wanted = [f for f in p["files"] if re.search(r"_(exl100|exeltel)(k7|rom)\.zip$", f, re.I)] + if not wanted: + continue + for f in wanted: + kind = "k7" if f.lower().endswith("k7.zip") else "rom" + url = BASE + rel.rsplit("/", 1)[0] + "/" + f if not f.startswith("http") else f + local = os.path.join(CACHE, os.path.basename(f)) + try: + if not os.path.exists(local): + open(local, "wb").write(get(url, binary=True)) + items = members(open(local, "rb").read()) + except Exception as e: + print(f" ! {f}: {e}", file=sys.stderr) + continue + for i, (name, data) in enumerate(items): + crc = zlib.crc32(data) & 0xffffffff + if crc in have_crc: + continue + if kind == "rom" and len(data) not in CART_SIZES: + print(f" ? {name}: taille cartouche inhabituelle {len(data)}", file=sys.stderr) + suffix = "" if len(items) == 1 else f"-{i + 1}" + slug = slug_of(p["title"]) + suffix + meta = {"t": p["title"] + ("" if not suffix else f" ({i + 1})"), "by": p["by"] or "DCExel", + "y": p["y"] or "", "cat": p["cat"], "src": "DCExel"} + out, titles, d = (f"{slug}.k7", tapes, TAPES) if kind == "k7" else (f"{slug}.bin", carts, CARTS) + if out in titles: + continue + print(f" + {out:34s} {len(data):6d} o {meta['t']} — {meta['by']} {meta['y']} [{meta['cat']}]", + file=sys.stderr) + if not dry: + open(os.path.join(d, out), "wb").write(data) + titles[out] = meta + have_crc.add(crc) + have_titles.add(norm(p["title"])) + added[kind] += 1 + if not dry: + for d, titles in ((TAPES, tapes), (CARTS, carts)): + with open(os.path.join(d, "titles.json"), "w", encoding="utf-8") as f: + json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f, + ensure_ascii=False, indent=1) + print(f"DCExel : {added['k7']} cassettes et {added['rom']} cartouches {'à ajouter' if dry else 'ajoutées'}", + file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/web/shell.html.in b/web/shell.html.in @@ -173,8 +173,9 @@ </div> <div id="programs" class="programs" role="listbox" aria-label="Programmes"></div> <p class="modal-src">Contenu préservé par le pack <b>TOSEC</b> sur - <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> ; chaque entrée - indique son éditeur et son année d'origine. ROM système : MAME.</p> + <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> et par la logithèque + <a href="http://dcexel.free.fr/">DCExel</a> de Daniel Coulom (transferts numériques des contributeurs) ; + chaque entrée indique son éditeur et son année d'origine. ROM système : MAME.</p> </dialog> <section class="screen"> @@ -232,7 +233,8 @@ <footer> <p><b>Sources.</b> EXL 100 et logiciels © Exelvision (1984), société dissoute. ROM système et cartouches préservées par le projet <a href="https://www.mamedev.org/">MAME</a>, le pack <b>TOSEC</b> sur - <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> et le projet de + <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a>, la logithèque + <a href="http://dcexel.free.fr/">DCExel</a> de Daniel Coulom et le projet de préservation Exelvision de <a href="https://www.ti99.com/exelvision/">ti99.com</a>. Émulation : <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> de Chusogar (d'après MAME), compilée en WebAssembly avec <a href="https://emscripten.org/">Emscripten</a>. Page de Jean Schurger, 2026.</p> @@ -442,7 +444,7 @@ await tap('Escape', 'Escape'); await sleep(7000); if (!alive()) return; // lecture (chargement rapide) for (const [c, k] of [['KeyR', 'r'], ['KeyU', 'u'], ['KeyN', 'n']]) await tap(c, k); await tap('Enter', 'Enter'); - if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN.'; + if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN. Certains programmes mettent jusqu’à une minute à s’initialiser.'; } function sourceOf(m) { const p = [];