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

exl100.patch (46101B)


      1 diff --git a/CMakeLists.txt b/CMakeLists.txt
      2 index a1ed784..676588b 100644
      3 --- a/CMakeLists.txt
      4 +++ b/CMakeLists.txt
      5 @@ -297,6 +297,7 @@ add_library(dsp_lib STATIC
      6    src/drivers/computers/apple2gs.cpp
      7    src/drivers/computers/exelv.cpp
      8    src/drivers/computers/exelv_tape.cpp
      9 +  src/drivers/computers/exelv_disk.cpp
     10    src/drivers/computers/ql.cpp
     11    src/drivers/computers/atari_st.cpp
     12    src/drivers/computers/amiga.cpp
     13 @@ -442,7 +443,7 @@ function(dsp_add_frontend target)
     14        "-sTOTAL_STACK=8388608"
     15        "-sEXIT_RUNTIME=0"
     16        "-sFORCE_FILESYSTEM=1"
     17 -      "-sEXPORTED_FUNCTIONS=['_dsp_web_start','_dsp_web_list','_dsp_web_has_embedded']"
     18 +      "-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','_dsp_web_disk_present','_dsp_web_disk_dirty','_dsp_web_disk_save','_dsp_web_disk_load','_dsp_web_tape_load']"
     19        "-sEXPORTED_RUNTIME_METHODS=['ccall','UTF8ToString','FS']"
     20        "-sMINIFY_HTML=0"
     21        "--shell-file=${CMAKE_CURRENT_BINARY_DIR}/shell.html")
     22 diff --git a/src/drivers/computers/exelv.cpp b/src/drivers/computers/exelv.cpp
     23 index debcb94..c1fad73 100644
     24 --- a/src/drivers/computers/exelv.cpp
     25 +++ b/src/drivers/computers/exelv.cpp
     26 @@ -2,6 +2,7 @@
     27  
     28  #include <algorithm>
     29  #include <cctype>
     30 +#include <cstdio>
     31  #include <cstring>
     32  #include "core/fs_compat.h"
     33  #include <fstream>
     34 @@ -94,6 +95,19 @@ Exelv::Exelv(Model model)
     35      maincpu_.set_port_in(Tms7000::kPortA, [this]() { return tms7020_porta_r(); });
     36      maincpu_.set_port_out(Tms7000::kPortB, [this](uint8_t v) { tms7020_portb_w(v); });
     37      maincpu_.set_cycle_handler([this](int c) { on_main_cycles(c); });
     38 +#ifdef CRAM_DBG
     39 +    // Trace le dispatcher de commandes du 7042 ($F1C8) et l'entrée des handlers de bloc.
     40 +    subcpu_.set_cycle_handler([this](int) {
     41 +        const uint16_t pc = subcpu_.pc();
     42 +        if (pc == 0xf1c8) std::fprintf(stderr, "7042 dispatch cmd=%02x cont=%02x%02x\n", unsigned(subcpu_.b()), unsigned(subcpu_.ram_at(45)), unsigned(subcpu_.ram_at(46)));
     43 +        else if (pc == 0xf1e4) std::fprintf(stderr, "7042 RESET par cmd %02x\n", unsigned(subcpu_.b()));
     44 +        else if (pc == 0xf006) std::fprintf(stderr, "7042 entre en RESET ($F006)\n");
     45 +        else if (pc == 0xf2d8) std::fprintf(stderr, "7042 handler $0B (bloc)\n");
     46 +        else if (pc == 0xf1a1) std::fprintf(stderr, "7042 poste %02x\n", unsigned(subcpu_.b()));
     47 +        else if (pc == 0xf4ac) std::fprintf(stderr, "7041 IR code brut %02x\n", unsigned(subcpu_.ram_at(7)));
     48 +        else if (pc == 0xf4b7) std::fprintf(stderr, "7041 IR touche R43=%02x R9=%02x R52=%02x\n", unsigned(subcpu_.ram_at(43)), unsigned(subcpu_.ram_at(9)), unsigned(subcpu_.ram_at(52)));
     49 +    });
     50 +#endif
     51  
     52      subcpu_.set_memory_handlers([this](uint16_t a) { return read_sub(a); },
     53                                  [this](uint16_t a, uint8_t v) { write_sub(a, v); });
     54 @@ -178,6 +192,30 @@ bool Exelv::load_bios(const std::string& rom_path, std::string* error) {
     55          if (loader.open(rom_path, &ignored) && loader.load(kExeltelSys, sys, &ignored)) {
     56              system_rom_ = std::move(sys);
     57          }
     58 +        // Exelmémoire (chantier) : si l'OS CROS64 est présent dans /roms, le
     59 +        // mapper en page 1. ROM 8 Ko à $D000-$EFFF, RAM 16 Ko à $8000-$BFFF.
     60 +        std::vector<uint8_t> os;
     61 +        if (read_plain_file("/roms/cros64.rom", os) && os.size() == 0x2000) {
     62 +            cram_os_ = std::move(os);
     63 +            cram_ram_.assign(0x4000, 0);  // CRAM vierge : $BFE1 != $AA -> présence OK
     64 +            cram_pram_.assign(0x0e00, 0);  // RAM $0200-$0FFF : pile logicielle, variables, volume A
     65 +            cram_present_ = true;
     66 +        }
     67 +    }
     68 +
     69 +    if (model_ == Model::Exl100) {
     70 +        // Exelmémoire 16K de l'EXL 100 : même carte (OS $D000-$EFFF, CRAM $8000-$BFFF), pas de RAM
     71 +        // basse. Le BIOS ($FF03-$FF17) lit $8000 : $AA -> lance $8001. C'est CROS64 qui écrit cette
     72 +        // amorce au Format ($DEDA-$DEE5, $DF38 : AA, BR @$E62A) ; on la sème sur CRAM vierge, comme
     73 +        // la cartouche livrée initialisée, sinon rien ne permet d'entrer dans CROS64 pour formater.
     74 +        std::vector<uint8_t> os;
     75 +        if (read_plain_file("/roms/cros64_exl100.rom", os) && os.size() == 0x2000) {
     76 +            cram_os_ = std::move(os);
     77 +            cram_ram_.assign(0x4000, 0);
     78 +            const uint8_t stub[4] = {0xaa, 0x8c, 0xe6, 0x2a};
     79 +            std::copy(stub, stub + 4, cram_ram_.begin());
     80 +            cram_present_ = true;
     81 +        }
     82      }
     83  
     84      if (main_rom.size() < (model_ == Model::Exl100 ? 0x800u : 0x1000u)) return false;
     85 @@ -249,6 +287,17 @@ bool Exelv::load_media(const std::string& path, std::string* error) {
     86      namespace fs = dsp::fsx;
     87      std::error_code ec;
     88      const std::string ext = lower_copy(fs::path(path).extension().string());
     89 +    if (ext == ".fd") {
     90 +        if (!read_plain_file(path, data)) {
     91 +            if (error) *error = "cannot read disk image " + path;
     92 +            return false;
     93 +        }
     94 +        if (!disk_.mount(std::move(data))) {
     95 +            if (error) *error = "bad .fd image (expected 655360 bytes) " + path;
     96 +            return false;
     97 +        }
     98 +        return true;
     99 +    }
    100      if (ext == ".k7" || ext == ".wav") {
    101          if (!read_plain_file(path, data)) {
    102              if (error) *error = "cannot read cassette " + path;
    103 @@ -286,6 +335,18 @@ bool Exelv::load_media(const std::string& path, std::string* error) {
    104  }
    105  
    106  void Exelv::reset() {
    107 +    // Exeldisk (EXL 100) : branché si son ROM d'amorçage est dans le MEMFS (la page le retire
    108 +    // quand aucune disquette n'est choisie, sinon le BIOS amorcerait sur le lecteur vide).
    109 +    if (model_ == Model::Exl100 && disk_rom_.empty()) {
    110 +        std::vector<uint8_t> rom;
    111 +        if (read_plain_file("/roms/exeldisk.rom", rom) && rom.size() == 0x2000) {
    112 +            disk_rom_ = std::move(rom);
    113 +            disk_present_ = true;
    114 +            disk_ram_.assign(0x7e00, 0);
    115 +            dos_ram_.assign(0x2000, 0);
    116 +        }
    117 +    }
    118 +    disk_.reset();
    119      ram_.fill(0);
    120      tms7020_portb_ = 0;
    121      tms7041_portb_ = 0;
    122 @@ -366,6 +427,16 @@ uint8_t Exelv::read_main(uint16_t address) {
    123      if (address == 0x0125) return vdp_.reg_r();
    124      if (address == 0x0128) return vdp_.initptr_r();
    125      if (address == 0x0130) return mailbox_wx319_r();
    126 +    if (disk_active()) {
    127 +        if (address == 0x0140) return disk_.read_status_port();
    128 +        if (address == 0x0142) return disk_.start_read();
    129 +        if (address == 0x0143) return disk_.finish_read();
    130 +        if (address >= 0x0200 && address <= 0x7fff && !disk_.cart_at_0200()) return disk_ram_[address - 0x0200];
    131 +        if (address >= 0x8000 && address <= 0x9fff && disk_.boot_rom_at_8000()) return disk_rom_[address - 0x8000];
    132 +        if (address >= 0xa000 && address <= 0xbfff && disk_.boot_rom_at_8000()) return 0xff;
    133 +        if (address >= 0xc800 && address <= 0xcfff) return disk_rom_[address - 0xc000];
    134 +        if (address >= 0xd000 && address <= 0xefff && !disk_.cos_at_d000()) return dos_ram_[address - 0xd000];
    135 +    }
    136      if (model_ == Model::Exeltel) {
    137          if (address == 0x0138) {
    138              page_bit1_ = true;
    139 @@ -377,7 +448,55 @@ uint8_t Exelv::read_main(uint16_t address) {
    140          }
    141          if (address == 0x0140) return p64_;
    142      }
    143 +    // Exelmémoire : RAM $0200-$0FFF en pages 0/1 (là où la fenêtre cartouche est vide).
    144 +    // CROS64 y met sa pile logicielle (R16 = $02D7), ses variables ($0260-$02D8) et le
    145 +    // volume A ($02DA+). Sans elle, les STA y sont perdus et les LDA relisent $FF : la
    146 +    // restauration de contexte ($D411) remettait BRTIME ($C004) à $FFFF, et le handler
    147 +    // INT2 faisait CALL $FFFF -> TRAP 0 -> reset -> écran rouge après Format.
    148 +    if (model_ == Model::Exeltel && cram_present_ && address >= 0x0200 && address <= 0x0fff) {
    149 +        const int pg = exeltel_page();
    150 +        if (pg != 2 && pg != 3 && pg != 6) return cram_pram_[address - 0x0200];
    151 +    }
    152 +#ifdef CRAM_DBG
    153 +    // Où lit le système (hors CROS64) dans la fenêtre cartouche des pages 0/1 ? (Exelquad, Autostart)
    154 +    if (cram_present_ && address >= 0x0200 && address <= 0x7fff && maincpu_.pc() < 0xd000) {
    155 +        static int n = 0;
    156 +        const int pg = exeltel_page();
    157 +        if (pg != 2 && pg != 3 && pg != 6 && n++ < 300)
    158 +            std::fprintf(stderr, "LOW %04x page=%d pc=%04x\n", unsigned(address), pg, unsigned(maincpu_.pc()));
    159 +    }
    160 +#endif
    161      if (address >= 0x0200 && address <= 0x7fff) return cart_r(uint16_t(address - 0x0200));
    162 +    // Exelmémoire : fenêtre haute fixe (non paginée). La présence est vue par
    163 +    // la vérif système $423A (CMPA @$D002=$55 / @$D003=$AA) qui lit $D000-$EFFF
    164 +    // directement en code page 2. RAM CRAM à $8000-$BFFF. Pas de $A5 à $C800
    165 +    // (sinon la détection boot auto-lance $8001 = RAM vide -> plantage).
    166 +    if (cram_present_) {
    167 +#ifdef CRAM_AUTOSTART
    168 +        // Expérience : octet d'autostart vu par la détection d'amorçage du 7040 (page 1, $C800 == $A5 ->
    169 +        // BRLANC $8001) pour lancer un programme machine stocké dans la CRAM.
    170 +        if (model_ == Model::Exeltel && address == 0xc800) return 0xa5;
    171 +#endif
    172 +        if (address >= 0x8000 && address <= 0xbfff) {
    173 +#ifdef CRAM_DBG_READS
    174 +            std::fprintf(stderr, "MR %04x pc=%04x\n", unsigned(address), unsigned(maincpu_.pc()));
    175 +#endif
    176 +            return cram_ram_[address - 0x8000];
    177 +        }
    178 +        if (address >= 0xd000 && address <= 0xefff) {
    179 +#ifdef CRAM_DBG
    180 +            // 1re exécution/lecture par adresse OS : trace le chemin CROS64.
    181 +            static bool seen[0x2000] = {false};
    182 +            if (!seen[address - 0xd000]) { seen[address - 0xd000] = true;
    183 +                std::fprintf(stderr, "OS %04x pc=%04x\n", unsigned(address), unsigned(maincpu_.pc())); }
    184 +#endif
    185 +            return cram_os_[address - 0xd000];
    186 +        }
    187 +        // $0131 = statut de sortie (imprimante/souris, bus série P49). L'OS
    188 +        // attend bit4=0 et ($1D)==$09 ; HLE « prêt » pour ne pas bloquer.
    189 +        if (address == 0x0131 && maincpu_.pc() >= 0xd000 && maincpu_.pc() <= 0xefff)
    190 +            return 0x09;  // HLE imprimante « prêt » seulement pour CROS64, pas le moniteur
    191 +    }
    192      if (address >= 0xc000 && address <= 0xc7ff) return ram_[address - 0xc000];
    193      return 0xff;
    194  }
    195 @@ -395,6 +514,20 @@ void Exelv::write_main(uint16_t address, uint8_t value) {
    196          mailbox_wx318_w(value);
    197          return;
    198      }
    199 +    if (disk_active()) {
    200 +        if (address == 0x0140) { disk_.write_control(value); return; }
    201 +        if (address == 0x0141) { disk_.write_register(value); return; }
    202 +        if (address == 0x0142 || address == 0x0143) return;  // choix de densité
    203 +        if (address >= 0x0200 && address <= 0x7fff) {
    204 +            if (!disk_.cart_at_0200()) disk_ram_[address - 0x0200] = value;
    205 +            return;
    206 +        }
    207 +        if (address >= 0x8000 && address <= 0xbfff && disk_.boot_rom_at_8000()) return;
    208 +        if (address >= 0xd000 && address <= 0xefff) {
    209 +            if (!disk_.cos_at_d000()) dos_ram_[address - 0xd000] = value;
    210 +            return;
    211 +        }
    212 +    }
    213      if (model_ == Model::Exeltel) {
    214          if (address == 0x0138) {
    215              page_bit1_ = false;
    216 @@ -409,6 +542,43 @@ void Exelv::write_main(uint16_t address, uint8_t value) {
    217              return;
    218          }
    219      }
    220 +    // Exelmémoire : RAM $0200-$0FFF (pages 0/1), voir read_main.
    221 +    if (model_ == Model::Exeltel && cram_present_ && address >= 0x0200 && address <= 0x0fff) {
    222 +        const int pg = exeltel_page();
    223 +        if (pg != 2 && pg != 3 && pg != 6) {
    224 +            cram_pram_[address - 0x0200] = value;
    225 +            return;
    226 +        }
    227 +    }
    228 +#ifdef CRAM_DBG
    229 +    // Écritures encore perdues (mémoire non mappée) : reste-t-il une zone à mapper ?
    230 +    if (model_ == Model::Exeltel && cram_present_ &&
    231 +        ((address >= 0x0200 && address <= 0x7fff) || (address >= 0xc800 && address <= 0xefff)))
    232 +        std::fprintf(stderr, "DROP %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
    233 +                     unsigned(maincpu_.pc()));
    234 +#endif
    235 +#ifdef CRAM_DBG
    236 +    // Trace port limitée aux écritures venant de CROS64 (pas la souris du moniteur $F0xx).
    237 +    if (model_ == Model::Exeltel && cram_present_ && (address == 0x0131 || address == 0x0132) &&
    238 +        maincpu_.pc() < 0xf000)
    239 +        std::fprintf(stderr, "PW %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
    240 +                     unsigned(maincpu_.pc()));
    241 +#endif
    242 +    if (cram_present_ && address >= 0x8000 && address <= 0xbfff) {
    243 +#ifdef CRAM_DBG
    244 +        std::fprintf(stderr, "MW %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
    245 +                     unsigned(maincpu_.pc()));
    246 +#endif
    247 +        cram_ram_[address - 0x8000] = value;
    248 +        cram_dirty_ = true;
    249 +        return;
    250 +    }
    251 +#ifdef CRAM_DBG
    252 +    // Qui écrit le vecteur BRTIME ($C004:$C005) que le handler INT2 appelle ?
    253 +    if (address == 0xc004 || address == 0xc005)
    254 +        std::fprintf(stderr, "VEC %04x=%02x pc=%04x\n", unsigned(address), unsigned(value),
    255 +                     unsigned(maincpu_.pc()));
    256 +#endif
    257      if (address >= 0xc000 && address <= 0xc7ff) ram_[address - 0xc000] = value;
    258  }
    259  
    260 @@ -416,10 +586,25 @@ uint8_t Exelv::read_sub(uint16_t) { return 0xff; }
    261  void Exelv::write_sub(uint16_t, uint8_t) {}
    262  
    263  uint8_t Exelv::mailbox_wx319_r() { return wx319_; }
    264 -void Exelv::mailbox_wx318_w(uint8_t data) { wx318_ = data; }
    265 +void Exelv::mailbox_wx318_w(uint8_t data) {
    266 +#ifdef CRAM_DBG
    267 +    {
    268 +        const uint8_t sp = maincpu_.sp();
    269 +        const unsigned r1 = (unsigned(maincpu_.ram_at(uint8_t(sp - 1))) << 8) | maincpu_.ram_at(sp);
    270 +        const unsigned r2 = (unsigned(maincpu_.ram_at(uint8_t(sp - 3))) << 8) | maincpu_.ram_at(uint8_t(sp - 2));
    271 +        std::fprintf(stderr, "MBX main->sub %02x pc=%04x ret=%04x,%04x\n", unsigned(data), unsigned(maincpu_.pc()), r1, r2);
    272 +    }
    273 +#endif
    274 +    wx318_ = data;
    275 +}
    276  
    277  uint8_t Exelv::tms7020_porta_r() {
    278      uint8_t data = (tms7041_portb_ & 0x80) ? 0x01 : 0x00;
    279 +    // Exeldisk : PA.1 = DRQ du 1770, PA.3 = état ROM/RAM de $0200.
    280 +    if (disk_active()) {
    281 +        if (disk_.drq()) data |= 0x02;
    282 +        if (disk_.cart_at_0200()) data |= 0x08;
    283 +    }
    284      // PA.4: cassette input, high when idle.
    285      if (!tape_.loaded() || tape_.level()) data |= 0x10;
    286      return data;
    287 @@ -447,10 +632,16 @@ uint8_t Exelv::tms7041_porta_r() {
    288  void Exelv::tms7041_portb_w(uint8_t data) {
    289      speech_.strobe_ws_rs(data & 0x03);
    290      if ((tms7041_portb_ & 0x04) && !(data & 0x04)) {
    291 +#ifdef CRAM_DBG
    292 +        std::fprintf(stderr, "MBX sub->main INT1 (wx319=%02x, sub pc=%04x)\n", unsigned(wx319_), unsigned(subcpu_.pc()));
    293 +#endif
    294          maincpu_.set_input_line(Tms7000::kInt1, IrqLine::Hold);
    295      }
    296      if (!(tms7041_portb_ & 0x40) && (data & 0x40)) {
    297          wx319_ = tms7041_portc_;
    298 +#ifdef CRAM_DBG
    299 +        std::fprintf(stderr, "MBX sub poste %02x\n", unsigned(wx319_));
    300 +#endif
    301          // Function $01 (key/joystick 0) is followed by the key code; $04
    302          // there reports the release.
    303          if (last_sent_ == 0x01 && wx319_ != 0x04) last_key_ = wx319_;
    304 @@ -460,6 +651,10 @@ void Exelv::tms7041_portb_w(uint8_t data) {
    305  }
    306  
    307  uint8_t Exelv::tms7041_portc_r() {
    308 +#ifdef CRAM_DBG
    309 +    if (!(tms7041_portb_ & 0x20))
    310 +        std::fprintf(stderr, "MBX sub lit %02x (sub pc=%04x)\n", unsigned(wx318_), unsigned(subcpu_.pc()));
    311 +#endif
    312      if (!(tms7041_portb_ & 0x20)) return wx318_;
    313      return 0xff;
    314  }
    315 @@ -503,9 +698,20 @@ void Exelv::fast_load_hook() {
    316      const uint16_t read_byte = exl ? 0xfe82 : 0xfcbd;
    317      const uint16_t pc = maincpu_.pc();
    318      if (pc == sync_start) {
    319 -        if (tape_.seek_sync()) maincpu_.set_pc(after_sync);
    320 +        const bool ok = tape_.seek_sync();
    321 +#ifdef CRAM_DBG
    322 +        std::fprintf(stderr, "TAPE sync %s\n", ok ? "ok" : "FAIL");
    323 +#endif
    324 +        if (ok) maincpu_.set_pc(after_sync);
    325      } else if (pc == read_byte) {
    326          const int value = tape_.next_byte();
    327 +#ifdef CRAM_DBG
    328 +        {
    329 +            static int n = 0;
    330 +            if (value < 0) std::fprintf(stderr, "TAPE fin de bande apres %d octets\n", n);
    331 +            else if (n++ < 48) std::fprintf(stderr, "TAPE byte %02x\n", unsigned(value));
    332 +        }
    333 +#endif
    334          if (value < 0) return;  // end of tape: let the BIOS time out
    335          maincpu_.set_a(uint8_t(value));
    336          // RETS: the low byte of the return address is on top of the stack.
    337 @@ -517,9 +723,28 @@ void Exelv::fast_load_hook() {
    338  
    339  void Exelv::on_main_cycles(int cycles) {
    340      main_cycles_ += uint64_t(cycles);
    341 +#ifdef CRAM_DBG
    342 +    // Anneau des derniers PC : vidé quand on retombe sur l'entrée RESET/TRAP 0
    343 +    // ($FCDB) après que CROS64 a tourné, pour trouver l'instruction fautive.
    344 +    {
    345 +        static uint16_t ring[64];
    346 +        static unsigned ri = 0;
    347 +        static bool armed = false;
    348 +        const uint16_t pc = maincpu_.pc();
    349 +        ring[ri++ & 63] = pc;
    350 +        if (!armed && pc >= 0xd000 && pc <= 0xefff) { armed = true; std::fprintf(stderr, "ARMED pc=%04x\n", unsigned(pc)); }
    351 +        if (armed && pc >= 0xfcdb && pc <= 0xfd9f) {
    352 +            std::fprintf(stderr, "TRAP0/RESET atteint, 64 derniers PC :\n");
    353 +            for (unsigned k = 0; k < 64; k++)
    354 +                std::fprintf(stderr, " %04x", unsigned(ring[(ri + k) & 63]));
    355 +            std::fprintf(stderr, "\n");
    356 +            armed = false;
    357 +        }
    358 +    }
    359 +#endif
    360      if (tape_fast_ && tape_playing_ && tape_.loaded()) fast_load_hook();
    361      if (tape_playing_ && tape_.loaded() && in_tape_read()) tape_.advance(cycles);
    362 -    speech_.tick(cycles);
    363 +    /* exl100_patch_speech: pilotée par update(), ce tick(cycles CPU) la faisait tourner ~4,8x trop vite */
    364      const uint32_t cpu_clock = maincpu_.cpu_clock();
    365      audio_accumulator_ += int64_t(cycles) * kSampleRate;
    366      while (audio_accumulator_ >= cpu_clock) {
    367 @@ -601,6 +826,9 @@ void Exelv::tick_keyboard(int cpu_cycles) {
    368      if (k_ch_byte_ < 2) {
    369          k_channels_[0] = scan_key_channel();
    370          if (k_channels_[0] != 0xff && k_ch_byte_ == 0) {
    371 +#ifdef CRAM_DBG
    372 +            std::fprintf(stderr, "KBD trame ch0=%02x ch1=%02x\n", unsigned(k_channels_[0]), unsigned(k_channels_[1]));
    373 +#endif
    374              if (k_channels_[1] == 0xff) k_channels_[1] = k_channels_[0];
    375              k_ch_bit_ = 0;
    376              k_bit_num_ = false;
    377 @@ -677,6 +905,22 @@ void Exelv::tick_keyboard(int cpu_cycles) {
    378  }
    379  
    380  void Exelv::run_frame() {
    381 +#ifdef CRAM_AUTONAV
    382 +    {
    383 +        // Auto-navigation déterministe (test) : après l'init, Bas ×3
    384 +        // (-> Périphériques) puis Entrée ×2 (-> Exelmémoire -> CROS64).
    385 +        static int nf = 0;
    386 +        nf++;
    387 +        struct Act { int at; Key k; };
    388 +        static const Act seq[] = {
    389 +            {560, Key::Down}, {610, Key::Down}, {660, Key::Down},
    390 +            {730, Key::Enter}, {800, Key::Enter}, {870, Key::Enter},
    391 +            {940, Key::Enter}, {1010, Key::Enter},
    392 +        };
    393 +        for (const Act& a : seq)
    394 +            if (nf >= a.at && nf < a.at + 3) inputs_.keys[size_t(a.k)] = true;
    395 +    }
    396 +#endif
    397      const uint32_t cpu_clock = maincpu_.cpu_clock();
    398      const int cycles_per_line = int(double(cpu_clock) / (kFramesPerSecond * kScanlines) + 0.5);
    399      for (int line = 0; line < kScanlines; line++) {
    400 @@ -696,6 +940,28 @@ void Exelv::run_frame() {
    401              tick_keyboard(slice);
    402              remain -= slice;
    403          }
    404 +#ifdef CRAM_DBG
    405 +        {
    406 +            // Anneau du PC par scanline (~10 instructions) : vidé la première fois que le PC
    407 +            // entre dans la zone reset/TRAP 0 ($FCDB-$FD9F) après que CROS64 a tourné.
    408 +            static uint16_t lring[512];
    409 +            static unsigned lri = 0;
    410 +            static bool larmed = false, ldone = false;
    411 +            const uint16_t lpc = maincpu_.pc();
    412 +            lring[lri++ & 511] = lpc;
    413 +            if (!larmed && lpc >= 0xd000 && lpc <= 0xefff) {
    414 +                larmed = true;
    415 +                std::fprintf(stderr, "LARMED pc=%04x\n", unsigned(lpc));
    416 +            }
    417 +            if (larmed && !ldone && lpc >= 0xfcdb && lpc <= 0xfd9f) {
    418 +                ldone = true;
    419 +                std::fprintf(stderr, "LTRAP0: 512 derniers PC (par scanline) :\n");
    420 +                for (unsigned k = 0; k < 512; k++)
    421 +                    std::fprintf(stderr, " %04x", unsigned(lring[(lri + k) & 511]));
    422 +                std::fprintf(stderr, "\n");
    423 +            }
    424 +        }
    425 +#endif
    426          // Without an I/O CPU ROM, post the "I/O CPU initialized" byte ($08)
    427          // the main CPU waits for. The INT1 handler reads it with PA.0 low,
    428          // raises PB.1 and waits for PA.0 to follow (tms7020_portb_w).
    429 @@ -709,6 +975,12 @@ void Exelv::run_frame() {
    430              }
    431          }
    432      }
    433 +#ifdef CRAM_PC
    434 +    if (cram_present_) {
    435 +        static int fr = 0;
    436 +        std::fprintf(stderr, "FR%04d PC=%04x SUB=%04x\n", fr++, unsigned(maincpu_.pc()), unsigned(subcpu_.pc()));
    437 +    }
    438 +#endif
    439      // Write a SAVE out once the BIOS has left the tape routine for a second.
    440      if (tape_.recording()) {
    441          if (in_tape_read()) {
    442 diff --git a/src/drivers/computers/exelv.h b/src/drivers/computers/exelv.h
    443 index b3857e8..89202ab 100644
    444 --- a/src/drivers/computers/exelv.h
    445 +++ b/src/drivers/computers/exelv.h
    446 @@ -6,6 +6,7 @@
    447  #include <vector>
    448  
    449  #include "core/machine.h"
    450 +#include "drivers/computers/exelv_disk.h"
    451  #include "drivers/computers/exelv_tape.h"
    452  #include "cpu/tms7000.h"
    453  #include "sound/tms5220.h"
    454 @@ -72,6 +73,32 @@ public:
    455      }
    456  
    457      bool bios_loaded() const { return bios_loaded_; }
    458 +    // Exelmémoire : contenu de la CRAM (16 Ko) pour la persistance côté page
    459 +    // (localStorage), comme le fichier .cram de DCExel.
    460 +    bool cram_present() const { return cram_present_; }
    461 +    const std::vector<uint8_t>& cram_data() const { return cram_ram_; }
    462 +    bool cram_load(const std::vector<uint8_t>& data) {
    463 +        if (!cram_present_ || data.size() != cram_ram_.size()) return false;
    464 +        cram_ram_ = data;
    465 +        cram_dirty_ = false;
    466 +        return true;
    467 +    }
    468 +    // Vrai si la CRAM a changé depuis le dernier appel (et remet le drapeau à zéro).
    469 +    bool cram_take_dirty() {
    470 +        const bool dirty = cram_dirty_;
    471 +        cram_dirty_ = false;
    472 +        return dirty;
    473 +    }
    474 +    // Exeldisk : présent si /roms/exeldisk.rom est là au démarrage ; image .fd montée par
    475 +    // load_media (--tape x.fd) ou disk_mount (page web), persistance comme la CRAM.
    476 +    bool disk_present() const { return disk_present_; }
    477 +    // L'Exeldisk n'est vu par le BIOS que si une disquette est montée : sans elle, l'EXL 100
    478 +    // démarre sur son logo (le ROM d'amorçage demanderait « Insert system disk »).
    479 +    bool disk_active() const { return disk_present_ && disk_.mounted(); }
    480 +    bool disk_mounted() const { return disk_.mounted(); }
    481 +    const std::vector<uint8_t>& disk_image() const { return disk_.image(); }
    482 +    bool disk_mount(std::vector<uint8_t> data) { return disk_.mount(std::move(data)); }
    483 +    bool disk_take_dirty() { return disk_.take_dirty(); }
    484      bool sub_present() const { return sub_present_; }
    485      uint16_t debug_pc() const { return maincpu_.pc(); }
    486      uint8_t debug_a() const { return maincpu_.a(); }
    487 @@ -125,6 +152,24 @@ private:
    488      std::array<uint8_t, 0x800> ram_{};
    489      std::vector<uint8_t> cart_;
    490      std::vector<uint8_t> system_rom_;
    491 +    // Exelmémoire (CRAM) — chantier. Page 1 : OS « CROS64 » à $D000-$EFFF
    492 +    // (ROM), RAM statique sauvegardée à $8000-$BFFF, octet de présence $A5 à
    493 +    // $C800 (détection boot $FDC6, emplacement B=0 : page 1, sig $A5, lance
    494 +    // $8001). Non déployé tant que non vérifié.
    495 +    std::vector<uint8_t> cram_os_;
    496 +    std::vector<uint8_t> cram_ram_;
    497 +    bool cram_present_ = false;
    498 +    // RAM $0200-$0FFF visible en pages 0/1 (fenêtre cartouche vide) : CROS64 y met sa pile
    499 +    // logicielle (R16 = $02D7), ses variables ($0260-$02D8) et le volume A ($02DA+).
    500 +    std::vector<uint8_t> cram_pram_;
    501 +    bool cram_dirty_ = false;  // CRAM modifiée depuis la dernière sauvegarde de la page
    502 +    // Exeldisk : ROM d'amorçage 8 Ko ($8000-$9FFF au démarrage, fenêtre $C800-$CFFF = ROM+$0800),
    503 +    // RAM 31,5 Ko à $0200-$7FFF (à la place de la cartouche), RAM DOS 8 Ko à $D000-$EFFF.
    504 +    ExelDisk disk_;
    505 +    std::vector<uint8_t> disk_rom_;
    506 +    std::vector<uint8_t> disk_ram_;
    507 +    std::vector<uint8_t> dos_ram_;
    508 +    bool disk_present_ = false;
    509  
    510      uint8_t tms7020_portb_ = 0;
    511      uint8_t tms7041_portb_ = 0;
    512 diff --git a/src/drivers/computers/exelv_disk.cpp b/src/drivers/computers/exelv_disk.cpp
    513 new file mode 100644
    514 index 0000000..088411c
    515 --- /dev/null
    516 +++ b/src/drivers/computers/exelv_disk.cpp
    517 @@ -0,0 +1,235 @@
    518 +#include "drivers/computers/exelv_disk.h"
    519 +
    520 +#include <algorithm>
    521 +
    522 +namespace dsp {
    523 +
    524 +namespace {
    525 +constexpr uint8_t kBusy = 0x01;
    526 +constexpr uint8_t kDrq = 0x02;       // type II/III ; INDEX en type I
    527 +constexpr uint8_t kLost = 0x04;      // type II/III ; TRK0 en type I
    528 +constexpr uint8_t kCrc = 0x08;
    529 +constexpr uint8_t kRnf = 0x10;
    530 +constexpr uint8_t kSpinUp = 0x20;    // type I ; RECORD TYPE en type II
    531 +constexpr uint8_t kWriteProt = 0x40;
    532 +constexpr uint8_t kMotor = 0x80;
    533 +}  // namespace
    534 +
    535 +void ExelDisk::reset() {
    536 +    control_ = 0;
    537 +    latch_ = 0xff;
    538 +    status_ = 0;
    539 +    track_ = 0;
    540 +    sector_ = 0;
    541 +    data_ = 0;
    542 +    drq_ = intrq_ = false;
    543 +    type1_ = true;
    544 +    op_ = Op::None;
    545 +    buffer_.clear();
    546 +    pos_ = 0;
    547 +}
    548 +
    549 +bool ExelDisk::mount(std::vector<uint8_t> image) {
    550 +    if (image.size() != kImageSize) return false;
    551 +    image_ = std::move(image);
    552 +    dirty_ = false;
    553 +    return true;
    554 +}
    555 +
    556 +uint8_t ExelDisk::read_status_port() const {
    557 +    // bit0 : disquette changée / absente (le ROM répond « drive not ready » si elle est à 1) ;
    558 +    // bit1 : lecteur sélectionné ; bit2 : prêt ; bit3 : INTRQ du 1770.
    559 +    uint8_t v = 0;
    560 +    if (!mounted()) v |= 0x01;
    561 +    if (control_ & 0x0c) v |= 0x02;
    562 +    if (mounted() && (control_ & 0x0c)) v |= 0x04;
    563 +    if (intrq_) v |= 0x08;
    564 +    return v;
    565 +}
    566 +
    567 +size_t ExelDisk::sector_offset(uint8_t track, uint8_t side, uint8_t sector) const {
    568 +    if (track >= kTracks || side >= kSides || sector >= kSectors) return kImageSize;
    569 +    return ((size_t(track) * kSides + side) * kSectors + sector) * kSectorSize;
    570 +}
    571 +
    572 +void ExelDisk::finish(uint8_t status_bits) {
    573 +    status_ = uint8_t((status_ & ~kBusy) | status_bits);
    574 +    if (mounted()) status_ |= kMotor;
    575 +    drq_ = false;
    576 +    intrq_ = true;
    577 +    op_ = Op::None;
    578 +}
    579 +
    580 +uint8_t ExelDisk::start_read() {
    581 +    latch_ = read_register();
    582 +    return latch_;
    583 +}
    584 +
    585 +uint8_t ExelDisk::read_register() {
    586 +    switch (control_ & 0x03) {
    587 +    case 0: {
    588 +        // Lire l'état acquitte INTRQ. En type I, bit1 = index (on le fait battre), bit2 = piste 0.
    589 +        intrq_ = false;
    590 +        uint8_t s = status_;
    591 +        if (type1_) {
    592 +            s = uint8_t(s & ~(kDrq | kLost));
    593 +            if (track_ == 0) s |= kLost;
    594 +            if (((index_++) & 0x0f) == 0) s |= kDrq;
    595 +        } else if (drq_) {
    596 +            s |= kDrq;
    597 +        }
    598 +        return s;
    599 +    }
    600 +    case 1: return track_;
    601 +    case 2: return sector_;
    602 +    default:
    603 +        if (op_ == Op::ReadSector || op_ == Op::ReadAddress) {
    604 +            if (pos_ < buffer_.size()) data_ = buffer_[pos_++];
    605 +            if (pos_ >= buffer_.size()) finish(0);
    606 +        }
    607 +        return data_;
    608 +    }
    609 +}
    610 +
    611 +void ExelDisk::write_register(uint8_t v) {
    612 +    switch (control_ & 0x03) {
    613 +    case 0: command(v); break;
    614 +    case 1: track_ = v; break;
    615 +    case 2: sector_ = v; break;
    616 +    default:
    617 +        data_ = v;
    618 +        if (op_ == Op::WriteSector) {
    619 +            if (pos_ < buffer_.size()) buffer_[pos_++] = v;
    620 +            if (pos_ >= buffer_.size()) {
    621 +                if (target_ + kSectorSize <= image_.size()) {
    622 +                    std::copy(buffer_.begin(), buffer_.end(), image_.begin() + long(target_));
    623 +                    dirty_ = true;
    624 +                }
    625 +                finish(0);
    626 +            }
    627 +        } else if (op_ == Op::WriteTrack) {
    628 +            write_track_byte(v);
    629 +        }
    630 +        break;
    631 +    }
    632 +}
    633 +
    634 +void ExelDisk::command(uint8_t cmd) {
    635 +    intrq_ = false;
    636 +    const uint8_t side = (control_ & 0x80) ? 1 : 0;
    637 +    // Type IV : interruption forcée (fin de l'ordre en cours).
    638 +    if ((cmd & 0xf0) == 0xd0) {
    639 +        op_ = Op::None;
    640 +        drq_ = false;
    641 +        status_ = uint8_t(status_ & ~(kBusy | kDrq));
    642 +        if (cmd & 0x0f) intrq_ = true;
    643 +        return;
    644 +    }
    645 +    if (!mounted()) {
    646 +        type1_ = (cmd & 0x80) == 0;
    647 +        status_ = kBusy;
    648 +        finish(kRnf);
    649 +        return;
    650 +    }
    651 +    if (!(cmd & 0x80)) {
    652 +        // Type I : restore / seek / step. Tout se fait d'un coup.
    653 +        type1_ = true;
    654 +        status_ = kBusy;
    655 +        switch (cmd >> 5) {
    656 +        case 0:
    657 +            if (cmd & 0x10) track_ = data_;  // seek
    658 +            else track_ = 0;                 // restore
    659 +            break;
    660 +        case 1: break;                                            // step (direction inconnue : rien)
    661 +        case 2: if (cmd & 0x10) track_ = uint8_t(track_ + 1); break;  // step in
    662 +        case 3: if (cmd & 0x10 && track_) track_--; break;            // step out
    663 +        }
    664 +        if (track_ >= kTracks) track_ = uint8_t(kTracks - 1);
    665 +        finish(kSpinUp);
    666 +        return;
    667 +    }
    668 +    type1_ = false;
    669 +    status_ = kBusy;
    670 +    switch (cmd >> 4) {
    671 +    case 0x8: case 0x9: {  // read sector
    672 +        const size_t off = sector_offset(track_, side, sector_);
    673 +        if (off >= image_.size()) { finish(kRnf); return; }
    674 +        buffer_.assign(image_.begin() + long(off), image_.begin() + long(off + kSectorSize));
    675 +        pos_ = 0;
    676 +        op_ = Op::ReadSector;
    677 +        drq_ = true;
    678 +        return;
    679 +    }
    680 +    case 0xa: case 0xb: {  // write sector
    681 +        target_ = sector_offset(track_, side, sector_);
    682 +        if (target_ >= image_.size()) { finish(kRnf); return; }
    683 +        buffer_.assign(kSectorSize, 0);
    684 +        pos_ = 0;
    685 +        op_ = Op::WriteSector;
    686 +        drq_ = true;
    687 +        return;
    688 +    }
    689 +    case 0xc: {  // read address : piste, face, secteur, taille (1 = 256), CRC
    690 +        buffer_ = {track_, side, sector_, 1, 0, 0};
    691 +        pos_ = 0;
    692 +        op_ = Op::ReadAddress;
    693 +        drq_ = true;
    694 +        return;
    695 +    }
    696 +    case 0xe: {  // read track : pas utilisé par le ROM ; on rend la piste brute des secteurs
    697 +        buffer_.clear();
    698 +        for (size_t s = 0; s < kSectors; s++) {
    699 +            const size_t off = sector_offset(track_, side, uint8_t(s));
    700 +            buffer_.insert(buffer_.end(), image_.begin() + long(off), image_.begin() + long(off + kSectorSize));
    701 +        }
    702 +        pos_ = 0;
    703 +        op_ = Op::ReadSector;
    704 +        drq_ = true;
    705 +        return;
    706 +    }
    707 +    case 0xf:  // write track (formatage) : on décode les champs ID/données du flux
    708 +        op_ = Op::WriteTrack;
    709 +        wt_count_ = 0;
    710 +        wt_state_ = 0;
    711 +        wt_idx_ = 0;
    712 +        drq_ = true;
    713 +        return;
    714 +    default:
    715 +        finish(kRnf);
    716 +        return;
    717 +    }
    718 +}
    719 +
    720 +void ExelDisk::write_track_byte(uint8_t v) {
    721 +    // Flux de formatage du ROM : ... $F5 $F5 $F5 $FE piste face secteur $01 $F7 ... $F5 $F5 $F5 $FB
    722 +    // données x256 $F7 ... ; $F5 = $A1 (sync), $F7 = CRC généré.
    723 +    switch (wt_state_) {
    724 +    case 0:  // en attente d'un marqueur
    725 +        if (v == 0xfe) { wt_state_ = 1; wt_idx_ = 0; }
    726 +        else if (v == 0xfb) {
    727 +            const size_t off = sector_offset(wt_id_[0], wt_id_[1] & 1, wt_id_[2]);
    728 +            target_ = off;
    729 +            buffer_.assign(kSectorSize, 0);
    730 +            pos_ = 0;
    731 +            wt_state_ = 2;
    732 +        }
    733 +        break;
    734 +    case 1:  // champ ID : piste, face, secteur, taille
    735 +        wt_id_[wt_idx_++] = v;
    736 +        if (wt_idx_ >= 4) wt_state_ = 0;
    737 +        break;
    738 +    case 2:  // champ de données
    739 +        if (pos_ < buffer_.size()) buffer_[pos_++] = v;
    740 +        if (pos_ >= buffer_.size()) {
    741 +            if (target_ + kSectorSize <= image_.size()) {
    742 +                std::copy(buffer_.begin(), buffer_.end(), image_.begin() + long(target_));
    743 +                dirty_ = true;
    744 +            }
    745 +            wt_state_ = 0;
    746 +        }
    747 +        break;
    748 +    }
    749 +    if (++wt_count_ >= kTrackBytes) finish(0);  // impulsion d'index : fin de piste
    750 +}
    751 +
    752 +}  // namespace dsp
    753 diff --git a/src/drivers/computers/exelv_disk.h b/src/drivers/computers/exelv_disk.h
    754 new file mode 100644
    755 index 0000000..94dc47c
    756 --- /dev/null
    757 +++ b/src/drivers/computers/exelv_disk.h
    758 @@ -0,0 +1,91 @@
    759 +#pragma once
    760 +
    761 +#include <cstdint>
    762 +#include <vector>
    763 +
    764 +namespace dsp {
    765 +
    766 +// Exeldisk : lecteur de disquettes 3"1/2 de l'EXL 100 (et de l'EXELTEL), piloté par un
    767 +// WD1770 derrière quatre registres du fichier périphérique (d'après EXELDOS.equ du
    768 +// devkit Jester et la ROM d'amorçage exeldisk.rom) :
    769 +//   P64 $0140  lecture : état du lecteur (bit0 disquette changée / absente, bit1 lecteur
    770 +//              sélectionné, bit2 prêt, bit3 INTRQ du 1770) ; écriture : verrou DSKCTL
    771 +//              (bits 0-1 registre du 1770, bit2/3 lecteur 1/2, bit4 $8000 = CRAM(1)/ROM
    772 +//              d'amorçage(0), bit5 $D000 = COS(1)/RAM DOS(0), bit6 $0200 = cartouche(1)/
    773 +//              RAM(0), bit7 face).
    774 +//   P65 $0141  écriture du registre du 1770 choisi par DSKCTL.
    775 +//   P66 $0142  lecture : lance la lecture du registre (verrouillée) ; écriture : double densité.
    776 +//   P67 $0143  lecture : rend l'octet verrouillé ; écriture : simple densité.
    777 +// DRQ du 1770 arrive sur le bit 1 du port A du 7020.
    778 +//
    779 +// Image .fd (DCExel / webmo) : 80 pistes x 2 faces x 16 secteurs x 256 octets = 655 360
    780 +// octets, à plat (piste, face, secteur), secteurs numérotés 0-15, double densité MFM.
    781 +// Le ROM d'amorçage lit les secteurs 4-15 et 32-47 (le DOS) en $D000-$EFFF.
    782 +class ExelDisk {
    783 +public:
    784 +    static constexpr size_t kTracks = 80;
    785 +    static constexpr size_t kSides = 2;
    786 +    static constexpr size_t kSectors = 16;
    787 +    static constexpr size_t kSectorSize = 256;
    788 +    static constexpr size_t kImageSize = kTracks * kSides * kSectors * kSectorSize;
    789 +    // Longueur d'une piste en double densité (300 tr/min, 250 kbit/s) : fin de WRITE TRACK.
    790 +    static constexpr int kTrackBytes = 6250;
    791 +
    792 +    void reset();
    793 +    bool mount(std::vector<uint8_t> image);
    794 +    bool mounted() const { return !image_.empty(); }
    795 +    const std::vector<uint8_t>& image() const { return image_; }
    796 +    bool take_dirty() {
    797 +        const bool d = dirty_;
    798 +        dirty_ = false;
    799 +        return d;
    800 +    }
    801 +
    802 +    // Verrou DSKCTL et ses lignes de sélection mémoire.
    803 +    uint8_t control() const { return control_; }
    804 +    void write_control(uint8_t v) { control_ = v; }
    805 +    bool boot_rom_at_8000() const { return !(control_ & 0x10); }
    806 +    bool cos_at_d000() const { return (control_ & 0x20) != 0; }
    807 +    bool cart_at_0200() const { return (control_ & 0x40) != 0; }
    808 +
    809 +    uint8_t read_status_port() const;  // P64
    810 +    void write_register(uint8_t v);     // P65
    811 +    uint8_t start_read();               // P66 (verrouille le registre)
    812 +    uint8_t finish_read() const { return latch_; }  // P67
    813 +    bool drq() const { return drq_; }
    814 +    bool intrq() const { return intrq_; }
    815 +
    816 +private:
    817 +    uint8_t read_register();
    818 +    void command(uint8_t cmd);
    819 +    size_t sector_offset(uint8_t track, uint8_t side, uint8_t sector) const;
    820 +    void finish(uint8_t status_bits);
    821 +    void write_track_byte(uint8_t v);
    822 +
    823 +    std::vector<uint8_t> image_;
    824 +    bool dirty_ = false;
    825 +    uint8_t control_ = 0;
    826 +    uint8_t latch_ = 0xff;
    827 +    // Registres du 1770.
    828 +    uint8_t status_ = 0;
    829 +    uint8_t track_ = 0;
    830 +    uint8_t sector_ = 0;
    831 +    uint8_t data_ = 0;
    832 +    bool drq_ = false;
    833 +    bool intrq_ = false;
    834 +    bool type1_ = true;  // dernier ordre de type I : bits d'état TRK0/INDEX
    835 +    int index_ = 0;      // impulsion d'index simulée (bit 1 des ordres de type I)
    836 +    // Transfert en cours.
    837 +    enum class Op { None, ReadSector, WriteSector, ReadAddress, WriteTrack };
    838 +    Op op_ = Op::None;
    839 +    std::vector<uint8_t> buffer_;
    840 +    size_t pos_ = 0;
    841 +    size_t target_ = 0;  // offset dans l'image du secteur en écriture
    842 +    // WRITE TRACK : analyse du flux (champs ID $FE et données $FB).
    843 +    int wt_count_ = 0;
    844 +    int wt_state_ = 0;
    845 +    uint8_t wt_id_[4] = {0, 0, 0, 0};
    846 +    int wt_idx_ = 0;
    847 +};
    848 +
    849 +}  // namespace dsp
    850 diff --git a/src/main.cpp b/src/main.cpp
    851 index 6525a1c..ed678ff 100644
    852 --- a/src/main.cpp
    853 +++ b/src/main.cpp
    854 @@ -15,6 +15,8 @@
    855  #include "core/machine.h"
    856  
    857  #include "frontend/machine_registry.h"
    858 +#include "drivers/computers/exelv.h"
    859 +#include <cstring>
    860  
    861  #include "core/embedded_media.h"
    862  #include "frontend/sdl_app.h"
    863 @@ -358,6 +360,101 @@ int main(int argc, char** argv) { return run_dsp(argc, argv); }
    864  // The page starts the embedded media when its URL names no game.
    865  extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_has_embedded() { return dsp_embedded_file_count > 0 ? 1 : 0; }
    866  
    867 +// Exelmémoire (driver exelv) : la page sauve et restaure la CRAM (16 Ko) en
    868 +// localStorage, en base64, comme le fichier .cram de DCExel. Base64 plutôt
    869 +// qu'un pointeur dans le tas : seuls ccall/UTF8ToString sont exportés.
    870 +static const char kCramB64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    871 +
    872 +static std::string cram_b64_encode(const std::vector<uint8_t>& in) {
    873 +    std::string out;
    874 +    out.reserve((in.size() + 2) / 3 * 4);
    875 +    for (size_t i = 0; i < in.size(); i += 3) {
    876 +        const uint32_t n = (uint32_t(in[i]) << 16) |
    877 +                           (i + 1 < in.size() ? uint32_t(in[i + 1]) << 8 : 0u) |
    878 +                           (i + 2 < in.size() ? uint32_t(in[i + 2]) : 0u);
    879 +        out += kCramB64[(n >> 18) & 63];
    880 +        out += kCramB64[(n >> 12) & 63];
    881 +        out += i + 1 < in.size() ? kCramB64[(n >> 6) & 63] : '=';
    882 +        out += i + 2 < in.size() ? kCramB64[n & 63] : '=';
    883 +    }
    884 +    return out;
    885 +}
    886 +
    887 +static bool cram_b64_decode(const char* s, std::vector<uint8_t>& out) {
    888 +    out.clear();
    889 +    uint32_t acc = 0;
    890 +    int bits = 0;
    891 +    for (; *s && *s != '='; ++s) {
    892 +        const char* p = std::strchr(kCramB64, *s);
    893 +        if (!p) return false;
    894 +        acc = (acc << 6) | uint32_t(p - kCramB64);
    895 +        bits += 6;
    896 +        if (bits >= 8) {
    897 +            bits -= 8;
    898 +            out.push_back(uint8_t((acc >> bits) & 0xff));
    899 +        }
    900 +    }
    901 +    return true;
    902 +}
    903 +
    904 +static dsp::Exelv* web_exelv() { return dynamic_cast<dsp::Exelv*>(g_web_machine.get()); }
    905 +
    906 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_present() {
    907 +    dsp::Exelv* m = web_exelv();
    908 +    return m && m->cram_present() ? 1 : 0;
    909 +}
    910 +
    911 +// Vrai si la CRAM a changé depuis le dernier appel.
    912 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_dirty() {
    913 +    dsp::Exelv* m = web_exelv();
    914 +    return m && m->cram_take_dirty() ? 1 : 0;
    915 +}
    916 +
    917 +extern "C" EMSCRIPTEN_KEEPALIVE const char* dsp_web_cram_save() {
    918 +    static std::string b64;
    919 +    dsp::Exelv* m = web_exelv();
    920 +    b64 = m && m->cram_present() ? cram_b64_encode(m->cram_data()) : std::string();
    921 +    return b64.c_str();
    922 +}
    923 +
    924 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_cram_load(const char* b64) {
    925 +    dsp::Exelv* m = web_exelv();
    926 +    std::vector<uint8_t> data;
    927 +    return m && b64 && cram_b64_decode(b64, data) && m->cram_load(data) ? 1 : 0;
    928 +}
    929 +
    930 +// Exeldisk : même mécanique que la CRAM (image .fd de 640 Ko en base64 côté page).
    931 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_disk_present() {
    932 +    dsp::Exelv* m = web_exelv();
    933 +    return m && m->disk_present() ? (m->disk_mounted() ? 2 : 1) : 0;
    934 +}
    935 +
    936 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_disk_dirty() {
    937 +    dsp::Exelv* m = web_exelv();
    938 +    return m && m->disk_take_dirty() ? 1 : 0;
    939 +}
    940 +
    941 +extern "C" EMSCRIPTEN_KEEPALIVE const char* dsp_web_disk_save() {
    942 +    static std::string b64;
    943 +    dsp::Exelv* m = web_exelv();
    944 +    b64 = m && m->disk_mounted() ? cram_b64_encode(m->disk_image()) : std::string();
    945 +    return b64.c_str();
    946 +}
    947 +
    948 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_disk_load(const char* b64) {
    949 +    dsp::Exelv* m = web_exelv();
    950 +    std::vector<uint8_t> data;
    951 +    return m && b64 && cram_b64_decode(b64, data) && m->disk_mount(std::move(data)) ? 1 : 0;
    952 +}
    953 +
    954 +// Cassette changée à chaud (face B, cassette 2…) : la machine continue, le BIOS lira la nouvelle bande
    955 +// au prochain chargement.
    956 +extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_tape_load(const char* path) {
    957 +    dsp::Exelv* m = web_exelv();
    958 +    std::string error;
    959 +    return m && path && m->load_media(path, &error) ? 1 : 0;
    960 +}
    961 +
    962  extern "C" EMSCRIPTEN_KEEPALIVE int dsp_web_start(const char* lines) {
    963      static std::vector<std::string> args;
    964      static std::vector<char*> argv;
    965 diff --git a/src/sound/mpeg_audio.cpp b/src/sound/mpeg_audio.cpp
    966 index ab4d563..7f2f930 100644
    967 --- a/src/sound/mpeg_audio.cpp
    968 +++ b/src/sound/mpeg_audio.cpp
    969 @@ -11,6 +11,7 @@
    970  #include <cmath>
    971  #include <cassert>
    972  #include <cstring>
    973 +#include <cstdlib>
    974  
    975  
    976  
    977 diff --git a/src/sound/tms5220.cpp b/src/sound/tms5220.cpp
    978 index e85a3d3..8ee3d1a 100644
    979 --- a/src/sound/tms5220.cpp
    980 +++ b/src/sound/tms5220.cpp
    981 @@ -1,3 +1,4 @@
    982 +#include <cstdio>
    983  #include "sound/tms5220.h"
    984  
    985  #include <algorithm>
    986 @@ -123,6 +124,9 @@ void Tms5220::chip_reset() {
    987  void Tms5220::set_interrupt_state(bool state) {
    988      if (state == irq_pin_) return;
    989      irq_pin_ = state;
    990 +#ifdef CRAM_DBG
    991 +    std::fprintf(stderr, "T5220 int=%d\n", state ? 1 : 0);
    992 +#endif
    993      if (irq_cb_) irq_cb_(state);
    994  }
    995  
    996 @@ -172,6 +176,9 @@ void Tms5220::data_write(uint8_t value) {
    997  }
    998  
    999  void Tms5220::process_command(uint8_t cmd) {
   1000 +#ifdef CRAM_DBG
   1001 +    std::fprintf(stderr, "T5220 cmd=%02x\n", unsigned(cmd));
   1002 +#endif
   1003      switch (cmd & 0x70) {
   1004          case 0x00:
   1005          case 0x20: // set rate on the TMS5220C, NOP otherwise
   1006 @@ -320,6 +327,9 @@ int Tms5220::read_bits(int count) {
   1007  
   1008      if (!ddis_) {
   1009          // No VSM attached: the bits float high, which decodes as a stop frame.
   1010 +#ifdef CRAM_DBG
   1011 +        { static int n = 0; if (n++ < 8) std::fprintf(stderr, "T5220 read_bits(%d) sans VSM\n", count); }
   1012 +#endif
   1013          return (1 << count) - 1;
   1014      }
   1015  
   1016 diff --git a/src/video/tms3556.cpp b/src/video/tms3556.cpp
   1017 index 288aa7e..49487a3 100644
   1018 --- a/src/video/tms3556.cpp
   1019 +++ b/src/video/tms3556.cpp
   1020 @@ -142,7 +142,15 @@ void Tms3556::draw_line_text_common(uint32_t* ln) {
   1021      uint16_t patterntbl_base[4];
   1022      for (int i = 0; i < 4; i++) patterntbl_base[i] = address_regs_[i + 3];
   1023  
   1024 -    const uint32_t border = rgb3(uint8_t(bg_color_));
   1025 +    // Attributs « série » du TMS3556 (d'après MAME tms3556.cpp) : la couleur de
   1026 +    // fond est portée par des cellules délimiteur (code caractère $20), pas par un
   1027 +    // fond global. En vidéo inverse le premier plan devient ce fond de zone ; c'est
   1028 +    // ce qui manquait (d'où le noir sur noir des menus, p. ex. Exeldrums).
   1029 +    const uint8_t margin_color = uint8_t((control_regs_[7] >> 5) & 0x7);  // VDP_CM4
   1030 +    uint8_t zone_bg = margin_color;
   1031 +    bool zone_masked = (control_regs_[7] & 0x08) != 0;
   1032 +
   1033 +    const uint32_t border = rgb3(margin_color);
   1034      int xx = 0;
   1035      for (; xx < kLeftBorder; xx++) *ln++ = border;
   1036  
   1037 @@ -151,32 +159,39 @@ void Tms3556::draw_line_text_common(uint32_t* ln) {
   1038      for (int x = 0; x < 40; x++) {
   1039          const int name_hi = read_byte(uint16_t(nametbl_base + name_offset));
   1040          const int name_lo = read_byte(uint16_t(nametbl_base + name_offset + 1));
   1041 -        const int pattern_ix = ((name_hi >> 2) & 2) | ((name_hi >> 4) & 1);
   1042 -        const bool alphanumeric = (pattern_ix < 2) || ((pattern_ix == 3) && !(control_regs_[7] & 0x08));
   1043 -        uint8_t fg = uint8_t((name_hi >> 5) & 0x7);
   1044 +        uint8_t fg;
   1045          uint8_t bg;
   1046          int dbl_w = 0;
   1047          int dbl_h = 0;
   1048 +        uint8_t pattern;
   1049 +        if ((name_lo & 0x7f) == 0x20) {
   1050 +            // Délimiteur : fixe le fond de la zone qui suit, affiché dans sa couleur.
   1051 +            fg = uint8_t((name_hi >> 5) & 0x7);
   1052 +            if (zone_masked && (control_regs_[5] & 0x20)) fg = zone_bg;
   1053 +            zone_bg = uint8_t(name_hi & 0x7);
   1054 +            zone_masked = (name_hi & 0x08) != 0;
   1055 +            bg = zone_bg;
   1056 +            pattern = 0xff;
   1057 +            if (char_line_counter_ == 0) dbl_h_phase_[x] = 0;
   1058 +        } else {
   1059 +        const int pattern_ix = ((name_hi >> 2) & 2) | ((name_hi >> 4) & 1);
   1060 +        const bool alphanumeric = (pattern_ix < 2) || ((pattern_ix == 3) && !(control_regs_[5] & 0x08));
   1061 +        fg = uint8_t((name_hi >> 5) & 0x7);
   1062          if (alphanumeric) {
   1063              if (name_hi & 4) {
   1064                  bg = fg;
   1065 -                fg = uint8_t(bg_color_);
   1066 +                fg = zone_bg;
   1067              } else {
   1068 -                bg = uint8_t(bg_color_);
   1069 +                bg = zone_bg;
   1070              }
   1071 -            // Software never pairs inverse video with double width: EXELTEL
   1072 -            // highlights menu entries with $1E and Capitaine Menkar draws
   1073 -            // $1F cells, both inverse with bit 1 set and no duplicated
   1074 -            // characters, which double width (left half / right half of
   1075 -            // consecutive cells) needs. Only honour bit 1 on normal video.
   1076 -            dbl_w = (name_hi & 0x4) ? 0 : (name_hi & 0x2);
   1077 +            dbl_w = name_hi & 0x2;
   1078              dbl_h = name_hi & 0x1;
   1079          } else {
   1080              bg = uint8_t(name_hi & 0x7);
   1081 +            zone_bg = bg;  // le fond mosaïque est un attribut série
   1082          }
   1083          if ((name_lo & 0x80) && blink_) fg = bg;
   1084  
   1085 -        uint8_t pattern;
   1086          if (!dbl_h) {
   1087              pattern = read_byte(uint16_t(patterntbl_base[pattern_ix] + (name_lo & 0x7f) +
   1088                                           128 * char_line_counter_));
   1089 @@ -190,6 +205,8 @@ void Tms3556::draw_line_text_common(uint32_t* ln) {
   1090                                               128 * (char_line_counter_ >> 1)));
   1091              if (char_line_counter_ == 0) dbl_h_phase_[x] = !dbl_h_phase_[x];
   1092          }
   1093 +        if (zone_masked && (control_regs_[5] & 0x20)) pattern = 0;  // zone masquée : espaces
   1094 +        }
   1095  
   1096          if (!dbl_w) {
   1097              for (int bit = 0; bit < 8; bit++) {