commit d015692c45f4c8b34b2920bf9931e486f2ac3846
parent a9715c897c365d30ddbf00e347da04e81100618f
Author: Jean Schurger <jean@schurger.org>
Date: Thu, 5 Nov 2026 08:29:26 -0500
Exelmémoire (CRAM) utilisable sur l'EXELTEL : CROS64, Format, Directory, persistance
Le driver exelv mappe la cartouche Exelmémoire quand roms/cros64.rom (OS CROS64 V 1.1,
= exelmemoire_64k.rom de DCExel) est embarquée : OS à $D000-$EFFF, RAM CRAM 16 Ko à
$8000-$BFFF, et une RAM $0200-$0FFF en pages 0/1 où CROS64 tient sa pile logicielle
(R16=$02D7), ses variables et le volume A. Sans cette dernière, la restauration de
contexte après Format relisait $FF et remettait le vecteur timer BRTIME ($C004) à $FFFF :
le handler INT2 appelait $FFFF, TRAP 0, reset logiciel, écran rouge.
La page sauve la CRAM en localStorage (base64, exports dsp_web_cram_*) dès qu'elle change
et la restaure au lancement, comme le fichier .cram de DCExel. Patch amont regroupé dans
patches/exl100.patch ; tests headless test/cram-format-trace.mjs et test/cram-persist.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
6 files changed, 486 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -11,3 +11,6 @@ roms/cartridges/
roms/tapes/
tools/wav2k7.js
tools/wav2k7.wasm
+build-dbg/
+web-dbg/
+web-cram/
diff --git a/patches/exl100.patch b/patches/exl100.patch
@@ -1,9 +1,154 @@
+diff --git a/CMakeLists.txt b/CMakeLists.txt
+index a1ed784..3ed811e 100644
+--- a/CMakeLists.txt
++++ b/CMakeLists.txt
+@@ -442,7 +442,7 @@ function(dsp_add_frontend target)
+ "-sTOTAL_STACK=8388608"
+ "-sEXIT_RUNTIME=0"
+ "-sFORCE_FILESYSTEM=1"
+- "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded']"
++ "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded','_dsp_web_cram_present','_dsp_web_cram_dirty','_dsp_web_cram_save','_dsp_web_cram_load']"
+ "-sEXPORTED_RUNTIME_METHODS=['ccall','UTF8ToString','FS']"
+ "-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..69b25ef 100644
+index debcb94..4911df1 100644
--- a/src/drivers/computers/exelv.cpp
+++ b/src/drivers/computers/exelv.cpp
-@@ -519,7 +519,7 @@ void Exelv::on_main_cycles(int cycles) {
+@@ -2,6 +2,7 @@
+
+ #include <algorithm>
+ #include <cctype>
++#include <cstdio>
+ #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) {
+ if (loader.open(rom_path, &ignored) && loader.load(kExeltelSys, sys, &ignored)) {
+ system_rom_ = std::move(sys);
+ }
++ // Exelmémoire (chantier) : si l'OS CROS64 est présent dans /roms, le
++ // mapper en page 1. ROM 8 Ko à $D000-$EFFF, RAM 16 Ko à $8000-$BFFF.
++ std::vector<uint8_t> os;
++ if (read_plain_file("/roms/cros64.rom", os) && os.size() == 0x2000) {
++ cram_os_ = std::move(os);
++ cram_ram_.assign(0x4000, 0); // CRAM vierge : $BFE1 != $AA -> présence OK
++ cram_pram_.assign(0x0e00, 0); // RAM $0200-$0FFF : pile logicielle, variables, volume A
++ 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) {
+ }
+ if (address == 0x0140) return p64_;
+ }
++ // Exelmémoire : RAM $0200-$0FFF en pages 0/1 (là où la fenêtre cartouche est vide).
++ // CROS64 y met sa pile logicielle (R16 = $02D7), ses variables ($0260-$02D8) et le
++ // volume A ($02DA+). Sans elle, les STA y sont perdus et les LDA relisent $FF : la
++ // restauration de contexte ($D411) remettait BRTIME ($C004) à $FFFF, et le handler
++ // INT2 faisait CALL $FFFF -> TRAP 0 -> reset -> écran rouge après Format.
++ if (model_ == Model::Exeltel && cram_present_ && address >= 0x0200 && address <= 0x0fff) {
++ const int pg = exeltel_page();
++ if (pg != 2 && pg != 3 && pg != 6) return cram_pram_[address - 0x0200];
++ }
+ 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
++ // 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 (address >= 0x8000 && address <= 0xbfff) {
++#ifdef CRAM_DBG_READS
++ std::fprintf(stderr, "MR %04x pc=%04x\n", unsigned(address), unsigned(maincpu_.pc()));
++#endif
++ return cram_ram_[address - 0x8000];
++ }
++ if (address >= 0xd000 && address <= 0xefff) {
++#ifdef CRAM_DBG
++ // 1re exécution/lecture par adresse OS : trace le chemin CROS64.
++ static bool seen[0x2000] = {false};
++ if (!seen[address - 0xd000]) { seen[address - 0xd000] = true;
++ std::fprintf(stderr, "OS %04x pc=%04x\n", unsigned(address), unsigned(maincpu_.pc())); }
++#endif
++ return cram_os_[address - 0xd000];
++ }
++ // $0131 = statut de sortie (imprimante/souris, bus série P49). L'OS
++ // attend bit4=0 et ($1D)==$09 ; HLE « prêt » pour ne pas bloquer.
++ if (address == 0x0131 && maincpu_.pc() >= 0xd000 && maincpu_.pc() <= 0xefff)
++ return 0x09; // HLE imprimante « prêt » seulement pour CROS64, pas le moniteur
++ }
+ 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) {
+ return;
+ }
+ }
++ // Exelmémoire : RAM $0200-$0FFF (pages 0/1), voir read_main.
++ if (model_ == Model::Exeltel && cram_present_ && address >= 0x0200 && address <= 0x0fff) {
++ const int pg = exeltel_page();
++ if (pg != 2 && pg != 3 && pg != 6) {
++ cram_pram_[address - 0x0200] = value;
++ return;
++ }
++ }
++#ifdef CRAM_DBG
++ // Écritures encore perdues (mémoire non mappée) : reste-t-il une zone à mapper ?
++ if (model_ == Model::Exeltel && cram_present_ &&
++ ((address >= 0x0200 && address <= 0x7fff) || (address >= 0xc800 && address <= 0xefff)))
++ std::fprintf(stderr, "DROP %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
++ unsigned(maincpu_.pc()));
++#endif
++#ifdef CRAM_DBG
++ // Trace port limitée aux écritures venant de CROS64 (pas la souris du moniteur $F0xx).
++ if (model_ == Model::Exeltel && cram_present_ && (address == 0x0131 || address == 0x0132) &&
++ maincpu_.pc() < 0xf000)
++ 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) {
++#ifdef CRAM_DBG
++ std::fprintf(stderr, "MW %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
++ unsigned(maincpu_.pc()));
++#endif
++ cram_ram_[address - 0x8000] = value;
++ cram_dirty_ = true;
++ return;
++ }
++#ifdef CRAM_DBG
++ // Qui écrit le vecteur BRTIME ($C004:$C005) que le handler INT2 appelle ?
++ if (address == 0xc004 || address == 0xc005)
++ std::fprintf(stderr, "VEC %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
++ unsigned(maincpu_.pc()));
++#endif
+ if (address >= 0xc000 && address <= 0xc7ff) ram_[address - 0xc000] = value;
+ }
+
+@@ -517,9 +599,28 @@ void Exelv::fast_load_hook() {
+
+ void Exelv::on_main_cycles(int cycles) {
main_cycles_ += uint64_t(cycles);
++#ifdef CRAM_DBG
++ // Anneau des derniers PC : vidé quand on retombe sur l'entrée RESET/TRAP 0
++ // ($FCDB) après que CROS64 a tourné, pour trouver l'instruction fautive.
++ {
++ static uint16_t ring[64];
++ static unsigned ri = 0;
++ static bool armed = false;
++ const uint16_t pc = maincpu_.pc();
++ ring[ri++ & 63] = pc;
++ if (!armed && pc >= 0xd000 && pc <= 0xefff) { armed = true; std::fprintf(stderr, "ARMED pc=%04x\n", unsigned(pc)); }
++ if (armed && pc >= 0xfcdb && pc <= 0xfd9f) {
++ std::fprintf(stderr, "TRAP0/RESET atteint, 64 derniers PC :\n");
++ for (unsigned k = 0; k < 64; k++)
++ std::fprintf(stderr, " %04x", unsigned(ring[(ri + k) & 63]));
++ std::fprintf(stderr, "\n");
++ armed = false;
++ }
++ }
++#endif
if (tape_fast_ && tape_playing_ && tape_.loaded()) fast_load_hook();
if (tape_playing_ && tape_.loaded() && in_tape_read()) tape_.advance(cycles);
- speech_.tick(cycles);
@@ -11,6 +156,199 @@ index debcb94..69b25ef 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) {
+ }
+
+ void Exelv::run_frame() {
++#ifdef CRAM_AUTONAV
++ {
++ // Auto-navigation déterministe (test) : après l'init, Bas ×3
++ // (-> Périphériques) puis Entrée ×2 (-> Exelmémoire -> CROS64).
++ static int nf = 0;
++ nf++;
++ struct Act { int at; Key k; };
++ static const Act seq[] = {
++ {560, Key::Down}, {610, Key::Down}, {660, Key::Down},
++ {730, Key::Enter}, {800, Key::Enter}, {870, Key::Enter},
++ {940, Key::Enter}, {1010, Key::Enter},
++ };
++ for (const Act& a : seq)
++ if (nf >= a.at && nf < a.at + 3) inputs_.keys[size_t(a.k)] = true;
++ }
++#endif
+ 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() {
+ tick_keyboard(slice);
+ remain -= slice;
+ }
++#ifdef CRAM_DBG
++ {
++ // Anneau du PC par scanline (~10 instructions) : vidé la première fois que le PC
++ // entre dans la zone reset/TRAP 0 ($FCDB-$FD9F) après que CROS64 a tourné.
++ static uint16_t lring[512];
++ static unsigned lri = 0;
++ static bool larmed = false, ldone = false;
++ const uint16_t lpc = maincpu_.pc();
++ lring[lri++ & 511] = lpc;
++ if (!larmed && lpc >= 0xd000 && lpc <= 0xefff) {
++ larmed = true;
++ std::fprintf(stderr, "LARMED pc=%04x\n", unsigned(lpc));
++ }
++ if (larmed && !ldone && lpc >= 0xfcdb && lpc <= 0xfd9f) {
++ ldone = true;
++ std::fprintf(stderr, "LTRAP0: 512 derniers PC (par scanline) :\n");
++ for (unsigned k = 0; k < 512; k++)
++ std::fprintf(stderr, " %04x", unsigned(lring[(lri + k) & 511]));
++ std::fprintf(stderr, "\n");
++ }
++ }
++#endif
+ // 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() {
+ }
+ }
+ }
++#ifdef CRAM_PC
++ if (model_ == Model::Exeltel && cram_present_) {
++ static int fr = 0;
++ std::fprintf(stderr, "FR%04d PC=%04x\n", fr++, unsigned(maincpu_.pc()));
++ }
++#endif
+ // Write a SAVE out once the BIOS has left the tape routine for a second.
+ if (tape_.recording()) {
+ if (in_tape_read()) {
+diff --git a/src/drivers/computers/exelv.h b/src/drivers/computers/exelv.h
+index b3857e8..a09827f 100644
+--- a/src/drivers/computers/exelv.h
++++ b/src/drivers/computers/exelv.h
+@@ -72,6 +72,22 @@ public:
+ }
+
+ bool bios_loaded() const { return bios_loaded_; }
++ // Exelmémoire : contenu de la CRAM (16 Ko) pour la persistance côté page
++ // (localStorage), comme le fichier .cram de DCExel.
++ bool cram_present() const { return cram_present_; }
++ const std::vector<uint8_t>& cram_data() const { return cram_ram_; }
++ bool cram_load(const std::vector<uint8_t>& data) {
++ if (!cram_present_ || data.size() != cram_ram_.size()) return false;
++ cram_ram_ = data;
++ cram_dirty_ = false;
++ return true;
++ }
++ // Vrai si la CRAM a changé depuis le dernier appel (et remet le drapeau à zéro).
++ bool cram_take_dirty() {
++ const bool dirty = cram_dirty_;
++ cram_dirty_ = false;
++ return dirty;
++ }
+ bool sub_present() const { return sub_present_; }
+ uint16_t debug_pc() const { return maincpu_.pc(); }
+ uint8_t debug_a() const { return maincpu_.a(); }
+@@ -125,6 +141,17 @@ private:
+ std::array<uint8_t, 0x800> ram_{};
+ std::vector<uint8_t> cart_;
+ std::vector<uint8_t> system_rom_;
++ // Exelmémoire (CRAM) — chantier. Page 1 : OS « CROS64 » à $D000-$EFFF
++ // (ROM), RAM statique sauvegardée à $8000-$BFFF, octet de présence $A5 à
++ // $C800 (détection boot $FDC6, emplacement B=0 : page 1, sig $A5, lance
++ // $8001). Non déployé tant que non vérifié.
++ std::vector<uint8_t> cram_os_;
++ std::vector<uint8_t> cram_ram_;
++ bool cram_present_ = false;
++ // RAM $0200-$0FFF visible en pages 0/1 (fenêtre cartouche vide) : CROS64 y met sa pile
++ // logicielle (R16 = $02D7), ses variables ($0260-$02D8) et le volume A ($02DA+).
++ std::vector<uint8_t> cram_pram_;
++ bool cram_dirty_ = false; // CRAM modifiée depuis la dernière sauvegarde de la page
+
+ uint8_t tms7020_portb_ = 0;
+ uint8_t tms7041_portb_ = 0;
+diff --git a/src/main.cpp b/src/main.cpp
+index 6525a1c..3022c6e 100644
+--- a/src/main.cpp
++++ b/src/main.cpp
+@@ -15,6 +15,8 @@
+ #include "core/machine.h"
+
+ #include "frontend/machine_registry.h"
++#include "drivers/computers/exelv.h"
++#include <cstring>
+
+ #include "core/embedded_media.h"
+ #include "frontend/sdl_app.h"
+@@ -358,6 +360,69 @@ int main(int argc, char** argv) { return run_dsp(argc, argv); }
+ // The page starts the embedded media when its URL names no game.
+ extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_has_embedded() { return dsp_embedded_file_count > 0 ? 1 : 0; }
+
++// Exelmémoire (driver exelv) : la page sauve et restaure la CRAM (16 Ko) en
++// localStorage, en base64, comme le fichier .cram de DCExel. Base64 plutôt
++// qu'un pointeur dans le tas : seuls ccall/UTF8ToString sont exportés.
++static const char kCramB64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
++
++static std::string cram_b64_encode(const std::vector<uint8_t>& in) {
++ std::string out;
++ out.reserve((in.size() + 2) / 3 * 4);
++ for (size_t i = 0; i < in.size(); i += 3) {
++ const uint32_t n = (uint32_t(in[i]) << 16) |
++ (i + 1 < in.size() ? uint32_t(in[i + 1]) << 8 : 0u) |
++ (i + 2 < in.size() ? uint32_t(in[i + 2]) : 0u);
++ out += kCramB64[(n >> 18) & 63];
++ out += kCramB64[(n >> 12) & 63];
++ out += i + 1 < in.size() ? kCramB64[(n >> 6) & 63] : '=';
++ out += i + 2 < in.size() ? kCramB64[n & 63] : '=';
++ }
++ return out;
++}
++
++static bool cram_b64_decode(const char* s, std::vector<uint8_t>& out) {
++ out.clear();
++ uint32_t acc = 0;
++ int bits = 0;
++ for (; *s && *s != '='; ++s) {
++ const char* p = std::strchr(kCramB64, *s);
++ if (!p) return false;
++ acc = (acc << 6) | uint32_t(p - kCramB64);
++ bits += 6;
++ if (bits >= 8) {
++ bits -= 8;
++ out.push_back(uint8_t((acc >> bits) & 0xff));
++ }
++ }
++ return true;
++}
++
++static dsp::Exelv* web_exelv() { return dynamic_cast<dsp::Exelv*>(g_web_machine.get()); }
++
++extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_present() {
++ dsp::Exelv* m = web_exelv();
++ return m && m->cram_present() ? 1 : 0;
++}
++
++// Vrai si la CRAM a changé depuis le dernier appel.
++extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_dirty() {
++ dsp::Exelv* m = web_exelv();
++ return m && m->cram_take_dirty() ? 1 : 0;
++}
++
++extern "C" EMSCRIPTEN_KEEPALIVE const char* dsp_web_cram_save() {
++ static std::string b64;
++ dsp::Exelv* m = web_exelv();
++ b64 = m && m->cram_present() ? cram_b64_encode(m->cram_data()) : std::string();
++ return b64.c_str();
++}
++
++extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_load(const char* b64) {
++ dsp::Exelv* m = web_exelv();
++ std::vector<uint8_t> data;
++ return m && b64 && cram_b64_decode(b64, data) && m->cram_load(data) ? 1 : 0;
++}
++
+ extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_start(const char* lines) {
+ static std::vector<std::string> args;
+ static std::vector<char*> argv;
diff --git a/src/sound/mpeg_audio.cpp b/src/sound/mpeg_audio.cpp
index ab4d563..7f2f930 100644
--- a/src/sound/mpeg_audio.cpp
diff --git a/roms/cros64.rom b/roms/cros64.rom
Binary files differ.
diff --git a/test/cram-format-trace.mjs b/test/cram-format-trace.mjs
@@ -0,0 +1,59 @@
+// Reproduit la nav EXELTEL -> Exelmémoire (CROS64) puis Format, en capturant la
+// trace PC par frame (CRAM_PC) et les écritures CRAM (CRAM_DBG) pour localiser le crash.
+// node test/cram-format-trace.mjs <url>
+import { open, sleep } from './cdp.mjs';
+import { writeFileSync } from 'node:fs';
+
+const url = process.argv[2] ?? 'http://127.0.0.1:8194/?game=exeltel&rom=/roms/exeltel.zip';
+const page = await open(url, { width: 900, height: 1100 });
+
+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} @${new Date().toISOString()} ===`);
+
+await sleep(8000); // boot EXELTEL
+await page.screenshot('/tmp/cf-0-menu.png');
+mark('AVANT NAV PERIPH');
+await down(); await down(); await down(); // -> Périphériques (4e)
+await page.screenshot('/tmp/cf-1-periph-hl.png');
+await enter(); // ouvre sous-menu Périphériques
+await sleep(1200);
+await page.screenshot('/tmp/cf-2-submenu.png');
+mark('AVANT LANCEMENT CROS64');
+await enter(); // Exelmémoire (1er) -> CROS64
+await sleep(3500);
+await page.screenshot('/tmp/cf-3-cros64.png');
+mark('CROS64 LANCE, AVANT NAV FORMAT');
+for (let i = 0; i < 7; i++) await down(); // Select A/B -> ... -> Format (8e)
+await page.screenshot('/tmp/cf-4-format-hl.png');
+mark('AVANT ENTREE FORMAT');
+await enter(); // Format
+await sleep(1200);
+await page.screenshot('/tmp/cf-5-format-prompt.png');
+mark('AVANT SAISIE NOM');
+await page.type('TEST'); // nom de volume
+await sleep(400);
+await page.screenshot('/tmp/cf-6-name.png');
+mark('AVANT VALIDATION FORMAT');
+await enter(); // valide -> écriture + (crash ?)
+await sleep(2500);
+await page.screenshot('/tmp/cf-7-after.png');
+mark('APRES FORMAT');
+
+// Dump : marqueurs + écritures (MW) + fin de la trace PC (FR)
+const logs = page.logs.map((l) => l.replace(/^\[\w+\]\s*/, ''));
+writeFileSync('/tmp/cf-raw.txt', logs.join('\n')); // logs bruts, non filtrés
+const important = logs.filter((l) => /^===|MW |PW |OS |ARMED|TRAP0/.test(l));
+const frames = logs.filter((l) => /^FR/.test(l));
+console.log('--- MARQUEURS + ECRITURES CRAM + PORTS ---');
+console.log(important.join('\n'));
+console.log('\n--- 80 DERNIERES FRAMES (PC) ---');
+console.log(frames.slice(-80).join('\n'));
+page.close();
diff --git a/test/cram-persist.mjs b/test/cram-persist.mjs
@@ -0,0 +1,53 @@
+// Persistance de l'Exelmémoire : formate le volume CROS64, laisse la page le sauver en
+// localStorage, RECHARGE la page, relance CROS64 et affiche Directory : le volume doit être là.
+// node test/cram-persist.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';
+const KEY = 'exl100.cram.exeltel';
+
+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);
+};
+const down = (p) => key(p, 'ArrowDown', 'ArrowDown', 40);
+const enter = (p) => key(p, 'Enter', 'Enter', 13);
+const toCros64 = async (p) => {
+ await sleep(8000); // boot EXELTEL
+ await down(p); await down(p); await down(p); // Périphériques
+ await enter(p); await sleep(1200);
+ await enter(p); await sleep(3500); // Exelmémoire -> CROS64
+};
+const lsLen = (p) => p.evaluate(`(localStorage.getItem(${JSON.stringify(KEY)}) || '').length`);
+
+// Phase 1 : vider la clé, lancer CROS64, formater, attendre la sauvegarde automatique.
+let page = await open(url, { width: 900, height: 1100 });
+await page.evaluate(`localStorage.removeItem(${JSON.stringify(KEY)})`);
+await toCros64(page);
+await page.screenshot('/tmp/cp-1-cros64.png');
+for (let i = 0; i < 7; i++) await down(page); // Format
+await enter(page); await sleep(1200);
+await enter(page); // valide (nom vide)
+await sleep(5000); // >= 2 ticks d'autosave (2 s)
+const len1 = await lsLen(page);
+await page.screenshot('/tmp/cp-2-after-format.png');
+console.log('Phase 1 : localStorage après Format =', len1, 'car. (attendu ~21848 pour 16 Ko)');
+page.close();
+await sleep(800);
+
+// Phase 2 : nouvelle page (même origine), CROS64 doit retrouver le volume : Directory.
+page = await open(url, { width: 900, height: 1100 });
+const len2 = await lsLen(page);
+await toCros64(page);
+await page.screenshot('/tmp/cp-3-cros64-reload.png');
+await down(page); await enter(page); // Directory
+await sleep(900);
+await enter(page); // sortie : Screen
+await sleep(1800);
+await page.screenshot('/tmp/cp-4-directory.png');
+const restored = page.logs.filter((l) => /Exelmémoire restaurée/.test(l));
+console.log('Phase 2 : clé présente au rechargement =', len2, 'car. ; log restauration :', restored.join(' | ') || '(aucun)');
+console.log('Captures : /tmp/cp-1-cros64.png /tmp/cp-2-after-format.png /tmp/cp-3-cros64-reload.png /tmp/cp-4-directory.png');
+page.close();
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -276,8 +276,39 @@
if (e !== 'unwind' && !(e && e.name === 'ExitStatus')) throw e;
}
canvas.focus();
+ cramRestore();
+ cramAutosave();
}
+ // Exelmémoire (EXELTEL) : la CRAM (16 Ko, volume CROS64) survit aux rechargements, en
+ // localStorage (base64), comme le fichier .cram de DCExel. Restaurée dès le lancement de
+ // la machine (avant que le système ne la lise), sauvée toutes les 2 s si elle a changé,
+ // et quand la page est masquée ou quittée.
+ const cramKey = 'exl100.cram.' + gameName;
+ let cramTimer = null;
+ function cramRestore() {
+ try {
+ if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return;
+ const b64 = localStorage.getItem(cramKey);
+ if (b64 && Module.ccall('dsp_web_cram_load', 'number', ['string'], [b64]))
+ console.log('Exelmémoire restaurée (' + b64.length + ' car.)');
+ } catch (e) {}
+ }
+ function cramSave(force) {
+ try {
+ if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return;
+ if (!force && !Module.ccall('dsp_web_cram_dirty', 'number', [], [])) return;
+ const b64 = Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], []));
+ if (b64) localStorage.setItem(cramKey, b64);
+ } catch (e) {}
+ }
+ function cramAutosave() {
+ if (cramTimer) clearInterval(cramTimer);
+ cramTimer = setInterval(() => cramSave(false), 2000);
+ }
+ document.addEventListener('visibilitychange', () => { if (document.hidden) cramSave(true); });
+ window.addEventListener('pagehide', () => cramSave(true));
+
function fillDrivers(selected) {
const type = typeSel.value;
const list = drivers.filter((d) => type === 'all' || d.type === type)