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 afef40a0af426b741289ae0e89306a97987dff72
parent 497c1defe31d3b3bdeeea70e2bcedf13fb54a3e0
Author: Jean Schurger <jean@schurger.org>
Date:   Thu,  5 Nov 2026 13:43:56 -0500

Jeux Exelmémoire : .bkp décodé par blocs, lancement EXL 100 automatique (CALL EXEC)

Les sauvegardes .BKP de webmo ne sont pas un simple dump inversé : à partir de l'\''offset $200, 64 blocs de
258 octets ($01 $00 + 256 octets). Sans retirer les marqueurs, l'\''image glissait de 2 octets par bloc et
les CALL des jeux tombaient dans du texte (Kamicar : $8574 attendu, trouvé à $8500). Corrigé dans
get_cram.py et dans la zone de dépôt.

Ces programmes sont du code machine EXL 100 (ils appellent ExelBASIC et le moniteur de l'\''EXL 100) :
la bibliothèque les lance maintenant sur l'\''EXL 100 — volume dans l'\''Exelmémoire, ExelBASIC, puis
« CALL EXEC(entrée) » tapé automatiquement (typist partagé avec le chargement de cassette). Entrée
déduite de l'\''image (BR @ en $8000 = 32768, $AA puis BR = 32769) sinon des étoiles de webmo.
Les 11 volumes démarrent (vérifié headless). L'\''Autostart EXELTEL marche aussi (Kamicar).

Tests : cram-autostart rythmé sur les trames émulées, library-cram sur l'\''EXL 100, CDP_PORT.

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

Diffstat:
Mtest/cdp.mjs | 2+-
Mtest/cram-autostart.mjs | 36+++++++++++++++++++++++++-----------
Atest/cram-boot.mjs | 18++++++++++++++++++
Atest/cram-exl100-cmd.mjs | 34++++++++++++++++++++++++++++++++++
Atest/cram-quad2.mjs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mtest/library-cram.mjs | 27++++++++++++++++-----------
Mtools/get_cram.py | 36+++++++++++++++++++++++++++---------
Mweb/shell.html.in | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
8 files changed, 231 insertions(+), 48 deletions(-)

diff --git a/test/cdp.mjs b/test/cdp.mjs @@ -4,7 +4,7 @@ import { writeFileSync } from 'node:fs'; export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -export async function open(url, { port = 9334, width = 1000, height = 1400 } = {}) { +export async function open(url, { port = Number(process.env.CDP_PORT ?? 9334), width = 1000, height = 1400 } = {}) { const chrome = spawn(process.env.CHROME ?? 'chromium', [ '--headless=new', '--no-sandbox', '--hide-scrollbars', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', diff --git a/test/cram-autostart.mjs b/test/cram-autostart.mjs @@ -1,7 +1,9 @@ // Volume Exelmémoire sur l'EXELTEL : Périphériques -> Autostart (5e). Captures + trace PC. +// Le rythme est calé sur les trames émulées (lignes « FRnnnn » de la build de debug), pas sur l'horloge : +// la build de debug tourne au ralenti pendant le démarrage. // node test/cram-autostart.mjs <volume.cram> [base-url] [prefix] import { open, sleep } from './cdp.mjs'; -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; const img = readFileSync(process.argv[2]); const base = process.argv[3] ?? 'http://127.0.0.1:8194'; @@ -9,22 +11,34 @@ 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 frame = () => { + for (let i = page.logs.length - 1; i >= 0; i--) { + const m = /FR(\d+) PC=/.exec(page.logs[i]); + if (m) return Number(m[1]); + } + return -1; +}; +const untilFrame = async (n) => { while (frame() < n) await sleep(100); }; +const frames = async (n) => untilFrame(frame() + n); const key = async (k, code, vk) => { await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code, windowsVirtualKeyCode: vk }); - await sleep(60); + await frames(4); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: vk }); - await sleep(320); + await frames(30); }; 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 untilFrame(Number(process.env.BOOT_FRAMES ?? 300)); +await down(); await down(); await down(); await enter(); await frames(60); // 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 launch = frame(); +await enter(); await frames(100); +await page.screenshot(prefix + '-1.png'); +await frames(400); +await page.screenshot(prefix + '-2.png'); +writeFileSync(prefix + '.log', page.logs.join('\n') + '\n'); 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(' ')); +console.log('lancement à la trame', launch, '; 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-boot.mjs b/test/cram-boot.mjs @@ -0,0 +1,18 @@ +// Démarre l'EXELTEL avec un volume .cram chargé, sans toucher au clavier : captures à 4 s et 12 s, +// et part des frames dont le PC est dans la CRAM ($8000-$BFFF). +// node test/cram-boot.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/cb'; +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()'); +await sleep(4000); await page.screenshot(prefix + '-1-4s.png'); +await sleep(8000); await page.screenshot(prefix + '-2-12s.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(-8).map((l) => l.split('PC=')[1]).join(' ')); +page.close(); diff --git a/test/cram-exl100-cmd.mjs b/test/cram-exl100-cmd.mjs @@ -0,0 +1,34 @@ +// EXL 100 + CROS 16K sur un volume .cram donné : tape des commandes (séparées par ;) et capture après chacune. +// node test/cram-exl100-cmd.mjs <volume.cram> "DIR;REN" [base-url] [prefix] +import { open, sleep } from './cdp.mjs'; +import { readFileSync } from 'node:fs'; + +const img = readFileSync(process.argv[2]); +const cmds = (process.argv[3] ?? 'DIR').split(';'); +const base = process.argv[4] ?? 'http://127.0.0.1:8195'; +const prefix = process.argv[5] ?? '/tmp/xc'; +const page = await open(base + '/?game=exl100&rom=/roms/exl100.zip&exelmem=1', { 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 type = async (s) => { + for (const ch of s) { + if (ch === ' ') await key(' ', 'Space', 32); + else if (ch === '.') await key('.', 'Period', 190); + 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); +let n = 0; +for (const c of cmds) { + if (c === 'ESC') await key('Escape', 'Escape', 27); + else if (c === 'ENTER') await key('Enter', 'Enter', 13); + else { await type(c); await key('Enter', 'Enter', 13); } + await sleep(1800); + await page.screenshot(`${prefix}-${n++}-${c.replace(/[^A-Za-z0-9]+/g, '_').slice(0, 12)}.png`); +} +console.log('captures', prefix + '-*.png'); +page.close(); diff --git a/test/cram-quad2.mjs b/test/cram-quad2.mjs @@ -0,0 +1,47 @@ +// Exelquad APRÈS initialisation de CROS64 : volume chargé, Périphériques -> Exelmémoire -> Quit, +// puis Exelquad (et Autostart). Captures + PC en CRAM + entrées CROS64 lues. +// node test/cram-quad2.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/q2'; +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); +const mark = (s) => page.logs.push('=== ' + s); +await sleep(9000); +await down(); await down(); await down(); await enter(); await sleep(1200); await enter(); await sleep(3500); // CROS64 +await page.screenshot(prefix + '-0-cros64.png'); +mark('QUIT'); +for (let i = 0; i < 8; i++) await down(); // Quit (9e) +await enter(); await sleep(3000); +await page.screenshot(prefix + '-1-after-quit.png'); +// Après Quit on est revenu dans le sous-menu Périphériques, « Exelmémoire » surligné. +mark('AUTOSTART'); +for (let i = 0; i < 4; i++) await down(); // Autostart (5e) +await enter(); await sleep(3000); +await page.screenshot(prefix + '-2-autostart.png'); +await key('Escape', 'Escape', 27); await sleep(800); +await key('Escape', 'Escape', 27); await sleep(800); // quitte le sous-menu -> Périphériques surligné +mark('EXELQUAD'); +await down(); await down(); await down(); // Pages, Terminal, Exelquad +await enter(); await sleep(6000); +await page.screenshot(prefix + '-3-exelquad.png'); +const logs = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, '')); +for (const ph of ['AUTOSTART', 'EXELQUAD']) { + const i = logs.indexOf('=== ' + ph); const j = ph === 'AUTOSTART' ? logs.indexOf('=== EXELQUAD') : logs.length; + const seg = logs.slice(i, j); const fr = seg.filter((l) => /^FR\d+ PC=/.test(l)); + console.log(ph, ': frames PC en CRAM', fr.filter((l) => /PC=[89ab]/.test(l)).length, '/', fr.length, + '; OS lus :', seg.filter((l) => /^OS /.test(l)).slice(0, 10).map((l) => l.split(' ')[1]).join(' ') || '(aucun)'); +} +page.close(); diff --git a/test/library-cram.mjs b/test/library-cram.mjs @@ -1,13 +1,15 @@ -// Volume Exelmémoire choisi dans la bibliothèque : clic sur l'entrée, la page bascule sur la machine -// visée (EXELTEL) avec le volume dans le localStorage et un message d'état. Capture après redémarrage. -// node test/library-cram.mjs [bout de titre] [base-url] [capture.png] +// Volume Exelmémoire choisi dans la bibliothèque (EXL 100) : clic sur l'entrée, le volume part dans le +// localStorage, la machine redémarre sur ExelBASIC avec l'Exelmémoire branchée et « CALL EXEC(n) » est tapé +// automatiquement. Captures à 12 s (BASIC + frappe) et 30 s (programme lancé). +// node test/library-cram.mjs [bout de titre] [base-url] [préfixe des captures] import { open, sleep } from './cdp.mjs'; -const want = (process.argv[2] ?? 'kong').toLowerCase(); +const want = (process.argv[2] ?? 'kamicar').toLowerCase(); const base = process.argv[3] ?? 'http://127.0.0.1:8195'; -const shot = process.argv[4] ?? '/tmp/lib-cram.png'; +const prefix = process.argv[4] ?? '/tmp/lib-cram'; const page = await open(base + '/', { width: 900, height: 1100 }); -await page.evaluate(`localStorage.removeItem('exl100.cram.exeltel')`); +await page.evaluate(`localStorage.removeItem('exl100.cram.exl100'); localStorage.removeItem('exl100.exelmem')`); +await page.evaluate('location.reload()'); await sleep(7000); const hit = await page.evaluate(`(() => { const b = [...document.querySelectorAll('.pg')].find((b) => b.dataset.type === 'cram' && b.dataset.search.includes(${JSON.stringify(want)})); @@ -16,9 +18,12 @@ const hit = await page.evaluate(`(() => { })()`); console.log('entrée cliquée :', hit); await sleep(12000); -const info = await page.evaluate(`({ href: location.href, status: document.getElementById('status').textContent, - cram: (() => { try { return Object.keys(localStorage).filter((k) => k.startsWith('exl100.cram')).map((k) => k + '=' + localStorage.getItem(k).length).join(' '); } catch (e) { return ''; } })() })`); -console.log('après :', JSON.stringify(info)); -await page.screenshot(shot); -console.log('Capture :', shot); +const state = () => page.evaluate(`({ href: location.href, status: document.getElementById('status').textContent, + cram: (() => { try { return Object.keys(localStorage).filter((k) => k.startsWith('exl100.')).map((k) => k + '=' + localStorage.getItem(k).length).join(' '); } catch (e) { return ''; } })() })`); +console.log('à 12 s :', JSON.stringify(await state())); +await page.screenshot(prefix + '-12s.png'); +await sleep(18000); +console.log('à 30 s :', JSON.stringify(await state())); +await page.screenshot(prefix + '-30s.png'); +console.log('Captures :', prefix + '-12s.png', prefix + '-30s.png'); page.close(); diff --git a/tools/get_cram.py b/tools/get_cram.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 -"""Volumes Exelmémoire pour la bibliothèque : les 11 sauvegardes .BKP de webmo.fr (programmes EXELTEL -diffusés par le serveur Exelvision, section « EXELMEMOIRES (CRAM) » de la page restauration-de-medias2) +"""Volumes Exelmémoire pour la bibliothèque : les 11 sauvegardes .BKP de webmo.fr (programmes EXL 100 en code +machine, qui appellent ExelBASIC et le moniteur de l'EXL 100, diffusés par le serveur télématique Exelvision ; +section « EXELMEMOIRES (CRAM) » de la page restauration-de-medias2 ; sur l'EXL 100 : ExelBASIC puis CALL EXEC(n)) et les deux volumes formatés vides de DCExel. Chaque volume devient roms/cram/<slug>.cram (16 Ko bruts, $8000-$BFFF) + roms/cram/titles.json ; non versionnés (roms/cram/ est ignoré), embarqués dans dsp.data. -Un .BKP = 514 octets d'en-tête puis la CRAM à l'envers ($BFFF d'abord). L'amorce d'autostart EXELTEL -(Périphériques -> Autostart : $BFE8 = $AA puis BR @$BFE9) est posée sur l'entrée indiquée par webmo +Un .BKP = 512 octets d'en-tête, un bloc de contrôle, puis 64 blocs de 258 octets (marqueur $01 $00 + 256 octets) +qui donnent la CRAM à l'envers ($BFFF d'abord) ; sans retirer les marqueurs, l'image glisse de 2 octets par +bloc. L'amorce d'autostart EXELTEL (Périphériques -> Autostart : $BFE8 = $AA puis BR @$BFE9) est posée sur +l'entrée indiquée par webmo (« CALL EXEC(n) » : * 32768, ** 32769, *** 32772, **** 40843, ***** 32784). Usage : python3 -I tools/get_cram.py [--dry-run] @@ -52,14 +55,27 @@ def slug_of(title): def bkp_to_cram(data): - """En-tête de 514 octets (vérifié par le bloc de contrôle : $BFE1 = $AA, zone libre $8004) puis 16 Ko inversés.""" - for h in (514, *range(0, 1100)): - img = data[h:h + 16384][::-1] + """64 blocs de 258 octets ($01 $00 + 256 octets) à partir de l'offset $200, CRAM à l'envers. Vérifié par le + bloc de contrôle du volume : $BFE1 = $AA, zone libre $8004.""" + blocks = [data[o + 2:o + 258] for o in range(0x200, 0x200 + 64 * 258, 258)] + if len(data) >= 0x200 + 64 * 258 and all(data[o:o + 2] == b"\x01\x00" for o in range(0x200, 0x200 + 64 * 258, 258)): + img = b"".join(blocks)[::-1] if len(img) == 16384 and img[0x3FE1] == 0xAA and img[0x3FE3:0x3FE5] == b"\x80\x04": return bytearray(img) raise ValueError("pas de volume CROS64 reconnu dans le .BKP") +def entry_of(img, legend): + """Entrée réelle d'après l'image : BR @ en $8000 = 32768 (Burgertime, Kong, Descente, Slalom, Sphinx) ; + $AA puis BR @ vers la CRAM en $8000 (amorce d'autostart EXL 100, on saute le $AA) = 32769 quand webmo + n'indique rien de plus précis (Pindo) ; sinon la valeur des étoiles de webmo.""" + if img[0] == 0x8C: + return 32768 + if img[0] == 0xAA and img[1] == 0x8C and 0x80 <= img[2] <= 0xBF and legend in (None, 32768): + return 32769 + return legend + + def with_autostart(img, entry): if entry: img[0x3FE8:0x3FEC] = bytes([0xAA, 0x8C, entry >> 8, entry & 0xFF]) @@ -72,12 +88,14 @@ def main(): titles = {} for fn, title, by, year, entry in WEBMO_VOLUMES: try: - img = with_autostart(bkp_to_cram(get(WEBMO + fn)), entry) + img = bkp_to_cram(get(WEBMO + fn)) + entry = entry_of(img, entry) + img = with_autostart(img, entry) except Exception as e: print(f" ! {fn}: {e}", file=sys.stderr) continue out = slug_of(title) + ".cram" - titles[out] = {"t": title, "by": by, "y": year, "cat": "jeu", "src": "webmo.fr", "machine": "exeltel", + titles[out] = {"t": title, "by": by, "y": year, "cat": "jeu", "src": "webmo.fr", "machine": "exl100", "entry": entry} print(f" + {out:36s} {title} — {by} (entrée {'$%04X' % entry if entry else '?'})", file=sys.stderr) if not dry: diff --git a/web/shell.html.in b/web/shell.html.in @@ -456,22 +456,34 @@ args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', '/roms/tapes/' + value.slice(5)); cassette = true; } else if (value.startsWith('cram:')) { - // Volume Exelmémoire : chargé dans le localStorage de la machine visée (EXELTEL pour les - // programmes webmo), puis démarrage sur cette machine ; le volume est restauré au lancement. + // 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 + // « CALL EXEC(entrée) » tapé automatiquement. Volumes EXELTEL : chargés puis machine redémarrée + // (Périphériques → Exelmémoire ou → Autostart). const file = value.slice(5); let data, meta = {}; try { data = fs().readFile('/roms/cram/' + file); meta = JSON.parse(fs().readFile('/roms/cram/titles.json', { encoding: 'utf8' }))[file] || {}; } catch (e) { statusEl.textContent = 'Volume introuvable : ' + file; return; } - const machine = meta.machine || 'exeltel'; + const machine = meta.machine || 'exl100'; let s = ''; for (let i = 0; i < data.length; i += 4096) s += String.fromCharCode.apply(null, data.subarray(i, i + 4096)); try { localStorage.setItem('exl100.cram.' + machine, btoa(s)); if (machine === 'exl100') localStorage.setItem('exl100.exelmem', '1'); - sessionStorage.setItem('exl100.msg', 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire' + - (machine === 'exeltel' ? ' : Périphériques → Exelmémoire (CROS64) ou → Autostart pour le lancer.' : ' : il démarre sur CROS.')); + } catch (e) {} + if (machine === 'exl100' && !onExeltel && exists('/roms/cros64_exl100.rom')) { + // Même page : l'Exelmémoire est encore branchée, on relance la machine directement. + args.push('--tape', '/roms/cartridges/exelbasic.bin', biosZip); + launch(args); + if (meta.entry) autoExec(token, meta.t || file, meta.entry); + else statusEl.textContent = 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire (entrée inconnue : voir CROS, commande DIR).'; + return; + } + try { + if (machine === 'exl100') sessionStorage.setItem('exl100.exec', value); + else sessionStorage.setItem('exl100.msg', 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire : Périphériques → Exelmémoire (CROS64) ou → Autostart pour le lancer.'); } catch (e) {} if ((machine === 'exeltel') !== onExeltel) { location.href = machine === 'exeltel' ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip' : location.pathname; @@ -485,10 +497,10 @@ if (cassette) autoLoadCassette(token); } - // Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC, en événements clavier synthétiques sur le - // canevas (SDL écoute #canvas). Le " de l'EXL 100 = Shift+3 ; les modificateurs sont de vraies - // touches Shift maintenues. S'annule si un autre programme est lancé entre-temps. - async function autoLoadCassette(token) { + // Frappe synthétique dans ExelBASIC : événements clavier sur le canevas (SDL écoute #canvas). Le " de + // l'EXL 100 = Shift+3, ( = Shift+5, ) = Shift+- ; les modificateurs sont de vraies touches Shift + // maintenues. S'annule si un autre programme est lancé entre-temps (jeton). + function typist(token) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const alive = () => token === bootToken; const send = (type, code, keyName, shift) => @@ -500,17 +512,43 @@ send('keydown', code, shifted, true); await sleep(70); send('keyup', code, shifted, true); await sleep(80); send('keyup', 'ShiftLeft', 'Shift', false); await sleep(120); }; + const text = async (str) => { + for (const ch of str) { + if (ch === '"') await sh('Digit3', '"'); + else if (ch === '(') await sh('Digit5', '('); + else if (ch === ')') await sh('Minus', ')'); + else if (ch === ' ') await tap('Space', ' '); + else if (/[0-9]/.test(ch)) await tap('Digit' + ch, ch); + else await tap('Key' + ch.toUpperCase(), ch.toLowerCase()); + } + }; + return { sleep, alive, tap, sh, text }; + } + + // Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC. + async function autoLoadCassette(token) { + const { sleep, alive, tap, text } = typist(token); statusEl.textContent = 'ExelBASIC — chargement automatique de la cassette…'; await sleep(4500); if (!alive()) return; // ExelBASIC prêt canvas.focus(); - for (const [c, k] of [['KeyL', 'l'], ['KeyO', 'o'], ['KeyA', 'a'], ['KeyD', 'd']]) await tap(c, k); - await sh('Digit3', '"'); await tap('Digit1', '1'); await sh('Digit3', '"'); + await text('LOAD"1"'); await tap('Enter', 'Enter'); await sleep(1500); 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 text('RUN'); await tap('Enter', 'Enter'); 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.'; } + + // Programme en Exelmémoire : CALL EXEC(entrée) ⏎ dans ExelBASIC. + async function autoExec(token, title, entry) { + const { sleep, alive, tap, text } = typist(token); + statusEl.textContent = 'ExelBASIC — lancement de « ' + title + ' » depuis l’Exelmémoire…'; + await sleep(4500); if (!alive()) return; // ExelBASIC prêt + canvas.focus(); + await text('CALL EXEC(' + entry + ')'); + await tap('Enter', 'Enter'); + if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez CALL EXEC(' + entry + ') puis Entrée.'; + } function sourceOf(m) { const p = []; if (m.by) p.push(m.by); @@ -636,8 +674,9 @@ }; // 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. + // cartouche = démarrage direct, image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel, .bkp webmo = en-tête + // de 512 octets puis 64 blocs de 258 octets, 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] || ''; @@ -675,7 +714,12 @@ } 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]; } + if (d.bkp) { + // 64 blocs de 258 octets ($01 $00 + 256 octets) à partir de $200, CRAM à l'envers ($BFFF d'abord). + const fwd = new Uint8Array(16384); + for (let b = 0; b < 64; b++) fwd.set(data.subarray(0x200 + b * 258 + 2, 0x200 + b * 258 + 258), b * 256); + vol = new Uint8Array(16384); for (let i = 0; i < 16384; i++) vol[i] = fwd[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 = ''; @@ -816,7 +860,10 @@ statusEl.textContent = drivers.length + ' drivers'; populatePrograms(); markActive(''); // le démarrage par défaut, c'est le logo - if (args.length > 0) launch(args); + let pending = null; + try { pending = sessionStorage.getItem('exl100.exec'); if (pending) sessionStorage.removeItem('exl100.exec'); } catch (e) {} + if (pending && !onExeltel) bootProgram(pending); // programme Exelmémoire choisi avant le rechargement + else if (args.length > 0) launch(args); else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]); // media built in }, };