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:
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'