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 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:
M.gitignore | 3+++
Mpatches/exl100.patch | 342++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aroms/cros64.rom | 0
Atest/cram-format-trace.mjs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/cram-persist.mjs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/shell.html.in | 31+++++++++++++++++++++++++++++++
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)