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

shell.html.in (67867B)


      1 <!doctype html>
      2 <html lang="fr">
      3 <head>
      4 <meta charset="utf-8">
      5 <meta name="viewport" content="width=device-width, initial-scale=1">
      6 <title>EXL 100 dans le navigateur</title>
      7 <meta name="description" content="Émulateur de l'Exelvision EXL 100 (1984) dans le navigateur : processeur Texas Instruments TMS7020, vidéo TMS3556, parole TMS5220, clavier infrarouge. Émulateur dsp-cpp compilé en WebAssembly.">
      8 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%2300e5e5'/%3E%3Cpath d='M8 3l3 3-3 3-3-3z' fill='%23e5004b'/%3E%3Cpath d='M8 6l3 3-3 3-3-3z' fill='%23f5a800'/%3E%3C/svg%3E">
      9 <style>
     10   :root {
     11     --bg: #15161a; --fg: #e8e6e0; --muted: #a7a49c;
     12     --accent: #e5004b; --cyan: #00e5e5;
     13     color-scheme: dark;
     14   }
     15   * { box-sizing: border-box; }
     16   html, body { margin: 0; padding: 0; }
     17   body {
     18     background: var(--bg); color: var(--fg);
     19     font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
     20     padding: 0 16px 40px;
     21   }
     22   a { color: #8fb8ff; }
     23   header { max-width: 900px; margin: 24px auto 12px; }
     24   h1 { font-size: 1.6rem; font-weight: 600; margin: 0 0 6px; letter-spacing: .01em; }
     25   .logo { font-weight: 800; color: #fff; }
     26   .logo span { color: var(--cyan); }
     27   .sub { color: var(--muted); margin: 0; }
     28   main { max-width: 900px; margin: 0 auto; }
     29   kbd { font: 0.85em ui-monospace, SFMono-Regular, Menlo, monospace;
     30     background: #2b2d35; border: 1px solid #3c3f49; border-bottom-width: 2px;
     31     border-radius: 4px; padding: 0 5px; color: #fff; white-space: nowrap; }
     32   code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff; }
     33 
     34   /* Écran */
     35   .screen { margin: 0 auto; max-width: 900px; }
     36   .bezel { background: #050505; border-radius: 14px; padding: 2.5%;
     37     box-shadow: 0 10px 40px rgba(0,0,0,.6), inset 0 0 0 2px #222; }
     38   #canvas { display: none; margin: 0 auto; background: var(--cyan);
     39     image-rendering: pixelated; outline: none;
     40     width: 100% !important; height: auto !important; aspect-ratio: 4 / 3;
     41     max-height: calc(100vh - 150px); object-fit: contain; }
     42   #canvas:focus { box-shadow: 0 0 0 2px var(--accent); }
     43   #grab { text-align: center; color: var(--accent); font-size: .85rem; margin: 8px 0 0; }
     44   #status { text-align: center; color: var(--muted); font-size: .85rem; margin: 8px 0 0;
     45     font-family: ui-monospace, SFMono-Regular, Menlo, monospace; min-height: 1.4em; }
     46 
     47   /* Barre d'outils */
     48   .toolbar { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center;
     49     align-items: center; margin: 14px auto 16px; max-width: 900px; }
     50   .toolbar label { color: var(--muted); font-size: .9rem; display: inline-flex; gap: 6px; align-items: center; }
     51   .toolbar .drop { cursor: pointer; border: 1px dashed var(--muted); border-radius: 8px; padding: 6px 12px;
     52     color: var(--fg, #ddd); transition: border-color .15s, background .15s; }
     53   .toolbar .drop:hover, .toolbar .drop.over { border-color: #6cf; background: rgba(102, 204, 255, .08); }
     54   .cram-body { padding: 12px 16px 16px; font-size: .9rem; color: var(--muted); }
     55   .cram-toggle { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin: 0 0 10px; }
     56   .cram-toggle label { color: #ddd; cursor: pointer; }
     57   .cram-sum { color: #ddd; margin: 6px 0; }
     58   #cram-modal table { border-collapse: collapse; width: 100%; font-family: ui-monospace, monospace; font-size: .85rem; }
     59   #cram-modal th, #cram-modal td { text-align: left; padding: 3px 8px; border-bottom: 1px solid #2a2c33; }
     60   .modal td.num { text-align: right; }
     61   #disk-files tr.run { cursor: pointer; }
     62   #disk-files tr.run:hover td { background: #2b2d35; color: #fff; }
     63   #disk-files td.go { color: var(--accent); }
     64   .pg-b { color: var(--accent); font-size: .78rem; margin-left: 8px; }
     65   .limits { padding-left: 18px; margin: 6px 0; }
     66   .limits li { margin: 4px 0; }
     67   .pg-side { margin-left: 6px; background: #1f3f3f; color: var(--accent); border: 1px solid #2b5c5c; border-radius: 6px; font-size: .75rem; padding: 1px 7px; cursor: pointer; }
     68   .pg-side:hover { background: #2a5555; color: #fff; }
     69   .pg-side.on { background: var(--accent); color: #000; border-color: var(--accent); }
     70   .pg-ins { margin-left: 8px; background: #2b2d35; color: #ddd; border: 1px solid #40434e; border-radius: 6px; font-size: .75rem; padding: 1px 6px; cursor: pointer; }
     71   .pg-ins:hover { background: #3a3d48; }
     72   #tape-sides { display: inline; }
     73   #tape-sides button { margin-left: 4px; }
     74   .cram-note { font-size: .82rem; margin: 6px 0; }
     75   .cram-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
     76   .toolbar select, .toolbar input[type=file], .toolbar button {
     77     background: #2b2d35; color: #fff; border: 1px solid #40434e; border-radius: 6px;
     78     padding: 7px 10px; font: inherit; font-size: .92rem; cursor: pointer; }
     79   .toolbar button:hover { background: #363946; }
     80   .toolbar button:active { background: var(--accent); border-color: var(--accent); }
     81   .toolbar input[type=file] { max-width: 230px; }
     82   .hidden { display: none !important; }
     83 
     84   /* Choix d'un programme : bouton + modale avec la liste (titre + source) */
     85   .pick-btn { background: var(--accent); color: #fff; border: 1px solid var(--accent); border-radius: 6px;
     86     padding: 7px 14px; font: inherit; font-size: .95rem; cursor: pointer; font-weight: 600; }
     87   .pick-btn:hover { filter: brightness(1.1); }
     88   .modal { width: min(640px, 94vw); max-height: 82vh; padding: 0; border: 1px solid #343842;
     89     border-radius: 12px; background: #17181c; color: var(--fg); overflow: hidden;
     90     box-shadow: 0 20px 60px rgba(0,0,0,.6); }
     91   .modal::backdrop { background: rgba(0,0,0,.6); }
     92   .modal-head { display: flex; align-items: center; gap: 12px; padding: 12px 14px;
     93     border-bottom: 1px solid #2a2c33; background: #1b1d22; }
     94   .modal-head strong { font-size: 1.05rem; }
     95   .modal-head #filter { flex: 1; background: #2b2d35; color: #fff; border: 1px solid #40434e;
     96     border-radius: 6px; padding: 6px 10px; font: inherit; font-size: .9rem; min-width: 0; }
     97   .modal-head .x { margin-left: auto; background: none; border: 0; color: var(--muted); font-size: 1.2rem; cursor: pointer; padding: 2px 6px; }
     98   #modal { width: min(960px, 96vw); }   /* Programmes : assez large pour une seule ligne de filtres */
     99   .modal-head .x:hover { color: #fff; }
    100   .modal-src { margin: 0; padding: 9px 14px; border-top: 1px solid #2a2c33; background: #1b1d22;
    101     color: var(--muted); font-size: .8rem; }
    102   .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; border-bottom: 1px solid #2a2c33; }
    103   .chip { background: #2b2d35; color: var(--muted); border: 1px solid #40434e; border-radius: 999px;
    104     padding: 4px 12px; font: inherit; font-size: .82rem; cursor: pointer; }
    105   .chip:hover { background: #363946; color: #fff; }
    106   .chip.active { background: var(--cyan); color: #06282a; border-color: var(--cyan); font-weight: 600; }
    107   /* Le défilement reste dans la liste (pas de chaînage vers la page) */
    108   .programs { max-height: 56vh; overflow-y: auto; overscroll-behavior: contain; background: #17181c; }
    109   .modal { overscroll-behavior: contain; }
    110   .modal[open] { display: flex; flex-direction: column; }   /* seulement ouverte : fermée, le navigateur la cache */
    111   /* Corps des modales Exelmémoire / Disquette : c'est lui qui défile, pas la page derrière. */
    112   .cram-body { overflow-y: auto; flex: 1 1 auto; min-height: 0; overscroll-behavior: contain; }
    113   body:has(dialog[open]) { overflow: hidden; }
    114   .pg-group { position: sticky; top: 0; background: #1b1d22; color: var(--cyan); font-size: .78rem;
    115     letter-spacing: .04em; text-transform: uppercase; padding: 6px 12px; border-bottom: 1px solid #2a2c33; }
    116   .pg { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; width: 100%;
    117     text-align: left; background: none; border: 0; border-bottom: 1px solid #202228; color: var(--fg);
    118     padding: 7px 12px; font: inherit; cursor: pointer; }
    119   .pg:hover { background: #22242b; }
    120   .pg.active { background: var(--accent); color: #fff; }
    121   .pg.active .pg-s { color: #ffd6e2; }
    122   .pg-t { font-size: .95rem; }
    123   .pg-s { color: var(--muted); font-size: .8rem; white-space: nowrap; flex-shrink: 0; }
    124   .pg.hide, .pg-group.hide { display: none; }
    125 
    126   /* Journal (replié) */
    127   #output { max-width: 900px; margin: 8px auto 0; white-space: pre-wrap; color: #7e7b74;
    128     font: 11px ui-monospace, monospace; max-height: 16vh; overflow: auto; }
    129 
    130   /* Aide */
    131   .guide { max-width: 900px; margin: 28px auto 0; }
    132   .guide h2 { font-size: 1.2rem; margin: 0 0 10px; }
    133   .guide h3 { font-size: 1rem; margin: 0 0 6px; color: #fff; }
    134   .guide p { margin: 0 0 10px; color: var(--fg); font-size: .95rem; }
    135   .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 24px; }
    136   table { border-collapse: collapse; font-size: .9rem; margin: 6px 0 10px; }
    137   td { padding: 3px 10px 3px 0; vertical-align: top; }
    138   td + td { color: var(--muted); }
    139   footer { max-width: 900px; margin: 36px auto 0; color: var(--muted); font-size: .8rem; }
    140 </style>
    141 </head>
    142 <body>
    143 <header>
    144   <h1><span class="logo">EXL<span>100</span></span> dans le navigateur</h1>
    145   <p class="sub">L'Exelvision EXL 100 (1984), l'ordinateur familial français à processeur Texas Instruments
    146     TMS7020, clavier infrarouge et synthèse vocale, émulé dans le navigateur.</p>
    147 </header>
    148 
    149 <main>
    150   <!-- Barre d'outils. Les contrôles « machine » et « driver » de dsp-cpp sont gardés (le script s'y
    151        réfère) mais masqués : la page est fixée sur l'EXL 100. Seul le chargement de média est offert. -->
    152   <div id="bar" class="toolbar">
    153     <label class="machine-sel" title="EXL 100 (ordinateur familial) ou EXELTEL (le terminal télématique à 64 Ko)">Machine
    154       <select id="machine">
    155         <option value="exl100">EXL 100</option>
    156         <option value="exeltel">EXELTEL (64 Ko)</option>
    157       </select>
    158     </label>
    159     <button id="cram-open" type="button" class="pick-btn" title="Extension Exelmémoire : 16 Ko de mémoire sauvegardée et son gestionnaire de fichiers CROS64 — brancher/débrancher, contenu du volume, import/export, vider.">💾 Exelmémoire</button>
    160     <button id="disk-open" type="button" class="pick-btn" hidden title="Contenu de la disquette montée dans l'Exeldisk : cliquez un programme pour le lancer">💽 Disquette</button>
    161     <span id="tape-sides"></span>
    162     <label class="hidden">Machine
    163       <select id="mtype">
    164         <option value="all">All</option><option value="arcade">Arcade</option>
    165         <option value="console">Console</option><option value="computer">Computer</option>
    166       </select>
    167     </label>
    168     <label class="hidden">Driver <select id="driver"><option value="">…</option></select></label>
    169     <label class="hidden">ROM <input id="rom" type="file"></label>
    170     <button id="pick" type="button" class="pick-btn" title="Parcourir les cartouches et cassettes">
    171       📂 Choisir un programme…</button>
    172     <label id="drop" class="drop" title="Cassette (.k7, .wav), cartouche (.bin, .rom), image Exelmémoire (.cram, .bkp) ou disquette (.fd, .td0) : le type est détecté et le fichier lancé comme il faut. Vous pouvez aussi le déposer sur l'écran.">
    173       📄 Déposer ou choisir un fichier
    174       <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav,.cram,.bkp,.fd,.td0" hidden>
    175     </label>
    176     <select id="mediatype" hidden>
    177       <option value="--tape">cartouche / cassette</option>
    178       <option value="--cart">cartouche (--cart)</option>
    179       <option value="--disk">disquette (--disk)</option>
    180     </select>
    181     <input id="extra" type="hidden" value="">
    182     <button id="start" hidden title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button>
    183     <details id="more" class="hidden"><summary>options</summary></details>
    184   </div>
    185 
    186   <!-- Modale Exelmémoire : brancher/débrancher, contenu du volume (lu côté page, à jour à chaque
    187        sauvegarde), import/export .cram, vider. -->
    188   <dialog id="disk-modal" class="modal">
    189     <div class="modal-head">
    190       <strong id="disk-title">Disquette</strong>
    191       <button id="disk-modal-close" type="button" class="x" aria-label="Fermer">✕</button>
    192     </div>
    193     <div class="cram-body">
    194       <p id="disk-summary" class="cram-sum"></p>
    195       <table id="disk-files"><thead><tr><th>Fichier</th><th>Ext</th><th>Taille</th><th>AU</th><th></th></tr></thead><tbody></tbody></table>
    196       <p class="cram-note">Cliquez un programme : la machine redémarre sur la disquette et la commande est tapée à l'invite
    197         EXELDOS — <code>LOADRUN NOM.BAS</code> (cartouche ExelBASIC insérée), <code>EXEC NOM</code> (binaire Jester, chargé
    198         en Exelmémoire) ou la commande <code>.OBJ</code>. Les autres fichiers (SVC, DSR, SYS, données) ne se lancent pas
    199         directement.</p>
    200     </div>
    201   </dialog>
    202   <dialog id="cram-modal" class="modal">
    203     <div class="modal-head">
    204       <strong>Exelmémoire</strong>
    205       <button id="cram-modal-close" type="button" class="x" aria-label="Fermer">✕</button>
    206     </div>
    207     <div class="cram-body">
    208       <p class="cram-toggle">
    209         <label><input type="checkbox" id="exelmem"> <b>Brancher l'Exelmémoire</b></label>
    210         <span id="exelmem-note" class="cram-note"></span>
    211       </p>
    212       <p id="cram-summary" class="cram-sum">Exelmémoire</p>
    213       <table id="cram-files"><thead><tr><th>Fichier</th><th>Ext</th><th>Taille</th><th>Enreg.</th><th>Drap.</th><th>Commentaire</th></tr></thead><tbody></tbody></table>
    214       <p id="cram-empty" class="cram-note"></p>
    215       <p class="cram-actions">
    216         <button id="cram-import" type="button" class="pick-btn" title="Charger une image .cram (format DCExel : 16 Ko brut, ou 64 Ko pour l'EXELTEL) ou une sauvegarde .bkp dans l'Exelmémoire">📥 Importer</button>
    217         <button id="cram-export" type="button" class="pick-btn" title="Télécharger le contenu de l'Exelmémoire en .cram (format DCExel)">📤 Exporter</button>
    218         <button id="cram-clear" type="button" class="pick-btn" title="Repartir d'un volume vierge (le contenu actuel est perdu, exportez-le d'abord si besoin)">🗑 Vider</button>
    219         <input type="file" id="cram-file" accept=".cram,.bkp,.bin,application/octet-stream" hidden>
    220       </p>
    221       <p class="cram-note">Le contenu est conservé dans ce navigateur (comme un fichier <code>.cram</code> de DCExel).
    222         Sur l'EXL 100, branchée, elle démarre à la place du logo (gestionnaire CROS) ; sur l'EXELTEL, menu
    223         <i>Périphériques → Exelmémoire</i>. Format : CROS64, enregistrements en descendant depuis $BFD0.</p>
    224     </div>
    225   </dialog>
    226 
    227   <dialog id="modal" class="modal">
    228     <div class="modal-head">
    229       <strong>Programmes</strong>
    230       <input id="filter" type="search" placeholder="Filtrer…" autocomplete="off">
    231       <button id="modal-close" type="button" class="x" aria-label="Fermer">✕</button>
    232     </div>
    233     <div id="chips" class="chips">
    234       <button class="chip active" type="button" data-f="all">Tous</button>
    235       <button class="chip" type="button" data-f="cart">Cartouches</button>
    236       <button class="chip" type="button" data-f="tape">Cassettes</button>
    237       <button class="chip" type="button" data-f="cram">Exelmémoire</button><button class="chip" type="button" data-f="disk">Disquettes</button>
    238       <button class="chip" type="button" data-f="jeu">Jeux</button>
    239       <button class="chip" type="button" data-f="application">Applications</button>
    240       <button class="chip" type="button" data-f="educatif">Éducatif</button>
    241       <button class="chip" type="button" data-f="demo">Démos</button>
    242     </div>
    243     <div id="programs" class="programs" role="listbox" aria-label="Programmes"></div>
    244     <p class="modal-src">Contenu préservé par le pack <b>TOSEC</b> sur
    245       <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a> et par la logithèque
    246       <a href="http://dcexel.free.fr/">DCExel</a> de Daniel Coulom (transferts numériques des contributeurs) ;
    247       chaque entrée indique son éditeur et son année d'origine. ROM système : MAME.</p>
    248   </dialog>
    249 
    250   <section class="screen">
    251     <div class="bezel"><canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas></div>
    252     <p id="grab" hidden>⌨️ Le clavier est pris par l'émulateur — cliquez en dehors de l'écran pour le rendre à la page.</p>
    253     <div id="status">Chargement…</div>
    254   </section>
    255 
    256   <div id="output"></div>
    257   <div id="help" hidden></div>
    258 
    259   <section class="guide">
    260     <h2>Comment ça marche</h2>
    261     <div class="cols">
    262       <div>
    263         <h3>L'écran de démarrage</h3>
    264         <p>Au lancement, la machine affiche le papillon arc-en-ciel et le logo <b>exelvision</b> sur fond cyan,
    265           exactement comme un EXL 100 à la mise sous tension. L'EXL 100 n'a pas de BASIC intégré : comme sur la
    266           vraie machine, tout vient d'une <b>cartouche</b> ou d'une <b>cassette</b>.</p>
    267         <p>« <b>Choisir un programme…</b> » ouvre la bibliothèque (chaque entrée indique sa source : éditeur
    268           et année) ; un filtre et des puces aident à s'y retrouver. Les <b>Cartouches</b> (ExelBASIC, Tennis,
    269           Guppy, Virus, Exelpaint…) démarrent seules. Les <b>Cassettes BASIC</b> — près de trois cents jeux,
    270           applications et cours — <b>se chargent toutes seules</b> : la page lance ExelBASIC puis tape
    271           <code>LOAD "1"</code>, <kbd>Échap</kbd> et <code>RUN</code> pour vous ; patientez quelques secondes
    272           (les gros programmes mettent jusqu'à une minute). Un programme sur plusieurs faces ou cassettes
    273           tient sur une ligne, avec un bouton par face. Les <b>volumes Exelmémoire</b> (jeux diffusés par le
    274           serveur Exelvision) se lancent sur l'EXL 100 par <code>CALL EXEC</code> tapé pour vous, et les
    275           <b>disquettes</b> amorcent sur EXELDOS. Vous pouvez aussi <b>déposer votre propre fichier</b>
    276           (cartouche <code>.bin</code>/<code>.rom</code>, cassette <code>.k7</code>/<code>.wav</code>, image
    277           Exelmémoire <code>.cram</code>/<code>.bkp</code>, disquette <code>.fd</code>) : son type est détecté.</p>
    278       </div>
    279       <div>
    280         <h3>L'Exelmémoire</h3>
    281         <p>L'extension <b>Exelmémoire</b> (16 Ko de mémoire sauvegardée par pile) et son gestionnaire de fichiers
    282           <b>CROS64</b> (Format, Directory, Tape→Cram, Cram→Tape…) sont émulés. Sur l'<b>EXELTEL</b> elle est
    283           toujours branchée : menu <i>Périphériques → Exelmémoire</i>. Sur l'<b>EXL 100</b>, le bouton
    284           « 💾 Exelmémoire » permet de la brancher : comme la vraie cartouche, elle démarre alors à la place du
    285           logo (gestionnaire CROS). Le même bouton montre le contenu du volume et l'importe ou l'exporte au format
    286           <code>.cram</code> de DCExel ; le contenu est conservé dans ce navigateur, <i>Format</i> l'efface.</p>
    287         <h3>L'Exeldisk</h3>
    288         <p>Le lecteur de disquettes 3"½ <b>Exeldisk</b> (contrôleur WD1770, 640 Ko par disquette) est émulé
    289           d'après les équates du devkit et son ROM d'amorçage. Choisissez une disquette dans la bibliothèque ou
    290           déposez une image <code>.fd</code> : l'EXL 100 amorce dessus (« exelvision BOOT V1.5 ») et charge
    291           <b>EXELDOS</b> ; à l'invite <code>[A]</code>, <code>DIR</code>, <code>HELP</code>, <code>EXEC</code>…
    292           Si la disquette contient des programmes <code>.BAS</code>, la cartouche ExelBASIC est insérée
    293           d'office (<code>LOADRUN NOM.BAS</code> les lance) ; les binaires d'<code>EXEC</code> se chargent dans
    294           l'Exelmémoire. Le bouton « 💽 Disquette » liste le contenu de la disquette en place : cliquer un
    295           programme le lance (la commande est tapée pour vous). Les écritures restent en mémoire le temps de
    296           la session.</p>
    297         <h3>Le clavier infrarouge</h3>
    298         <p>Le clavier et les manettes de l'EXL 100 étaient sans fil, reliés en infrarouge. Ici c'est votre clavier
    299           d'ordinateur, aux positions de MAME. Cliquez dans l'écran : il prend le clavier (un message le dit,
    300           espace et flèches ne font plus défiler la page) ; cliquez ailleurs pour le rendre.</p>
    301         <p>Les touches de fonction : <kbd>F2</kbd> pause, <kbd>F3</kbd> réinitialisation, <kbd>F6</kbd> pause
    302           cassette, <kbd>F12</kbd> turbo. Quand un programme réclame une autre face ou cassette, le bouton
    303           « ⏏ Insérer Face B » de la barre la met en place sans redémarrer (le bouton du programme indique la
    304           face en cours ; « ⏏ insérer » dans la liste change de cassette à chaud). Dans ExelBASIC,
    305           <code>LOAD"1"</code> lit la cassette et <code>SAVE"1"</code> enregistre.</p>
    306         <h3>Limites actuelles</h3>
    307         <ul class="limits">
    308           <li>Tout n'a pas été essayé : la plupart des programmes n'ont été vus qu'à leur écran de titre.</li>
    309           <li>Pas d'images Teledisk (<code>.td0</code>) : convertissez-les en <code>.fd</code>. Un seul lecteur
    310             de disquettes, pas d'Exeldisk sur l'EXELTEL, et les écritures sur disquette ne survivent pas au
    311             rechargement de la page (l'Exelmémoire, elle, est conservée).</li>
    312           <li>Sur l'EXELTEL : pas de ligne téléphonique ni de Minitel, donc Téléphone, Répondeur,
    313             Communication et Terminal restent sans effet ; CROS64 refuse les cassettes ExelBASIC en
    314             Tape→Cram (format d'enregistrements différent).</li>
    315           <li>Pas de manettes physiques : les manettes sont sur le clavier (positions de MAME). La boîte à
    316             rythmes Exeldrums reste muette : ses sons venaient d'une puce PCF0705 non émulée.</li>
    317           <li>Les cassettes qui n'existent qu'en enregistrement audio (<code>.wav</code>, chez webmo.fr) se
    318             chargent mal : la bibliothèque s'en tient aux transferts numériques (TOSEC, DCExel). La parole du
    319             TMS5220 est émulée, sa justesse n'a pas été comparée à la machine réelle.</li>
    320         </ul>
    321       </div>
    322       <div>
    323         <h3>Sous le capot</h3>
    324         <p>La machine a deux processeurs Texas Instruments TMS7000 (le TMS7020 principal et un TMS7041 d'entrées
    325           /sorties qui dialoguent par une boîte aux lettres), un contrôleur vidéo <b>TMS3556</b> (texte 40×25 ou
    326           320×250 en 8 couleurs) et un synthétiseur vocal <b>TMS5220</b>.</p>
    327         <p>L'émulation est le driver <code>exelv</code> de l'émulateur multi-machines
    328           <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> (d'après MAME), compilé en WebAssembly avec
    329           Emscripten. La ROM système (BIOS, 6 Ko) est embarquée dans la page.</p>
    330       </div>
    331     </div>
    332   </section>
    333 </main>
    334 
    335 <footer>
    336   <p><b>Sources.</b> EXL 100 et logiciels © Exelvision (1984), société dissoute. ROM système et cartouches
    337     préservées par le projet <a href="https://www.mamedev.org/">MAME</a>, le pack <b>TOSEC</b> sur
    338     <a href="https://archive.org/details/pack-roms-exelvision-exl-100">archive.org</a>, la logithèque
    339     <a href="http://dcexel.free.fr/">DCExel</a> de Daniel Coulom et le projet de
    340     préservation Exelvision de <a href="https://www.ti99.com/exelvision/">ti99.com</a>. Émulation :
    341     <a href="https://github.com/Chusogar/dsp-cpp">dsp-cpp</a> de Chusogar (d'après MAME), compilée en
    342     WebAssembly avec <a href="https://emscripten.org/">Emscripten</a>. Page de Jean Schurger, 2026.</p>
    343   <p><b>Tout n'a pas été testé.</b> Plus de trois cents programmes, deux machines, l'Exelmémoire et l'Exeldisk :
    344     une partie seulement a été essayée. Si quelque chose ne démarre pas, s'affiche mal ou se comporte autrement
    345     que sur la vraie machine, écrivez-moi à <a href="mailto:jean@schurger.org">jean@schurger.org</a> en indiquant
    346     le programme et ce que vous avez vu.</p>
    347 </footer>
    348 
    349 <script>
    350 (() => {
    351   // Everything lives in this scope: the Emscripten runtime script defines
    352   // globals of its own (run, out, err, ...). Only Module is shared.
    353   // Arguments the page starts with when the URL gives none (set by CMake).
    354   const DSP_DEFAULT_ARGS = "@DSP_WEB_ARGS@";
    355 
    356   const $ = (id) => document.getElementById(id);
    357   const statusEl = $('status'), outputEl = $('output'), canvas = $('canvas');
    358   const typeSel = $('mtype'), driverSel = $('driver'), romInput = $('rom');
    359   // Machine courante : EXL 100 (défaut) ou EXELTEL (terminal 64 Ko). L'EXELTEL est rétrocompatible
    360   // avec les cartouches/cassettes EXL 100 (elles répondent en page 6 au démarrage), donc la
    361   // bibliothèque de programmes marche aussi sur l'EXELTEL, lancée en mode exeltel.
    362   const onExeltel = new URLSearchParams(location.search).get('game') === 'exeltel';
    363   const gameName = onExeltel ? 'exeltel' : 'exl100';
    364   const biosZip = onExeltel ? '/roms/exeltel.zip' : '/roms/exl100.zip';
    365   let drivers = [];          // [{name, title, type, aliases}] from dsp_web_list()
    366   let waitingForRom = false; // a driver was picked and the file dialog is open
    367 
    368   function log(text) { outputEl.textContent += text + '\n'; outputEl.scrollTop = outputEl.scrollHeight; }
    369 
    370   function splitArgs(text) {
    371     const out = [];
    372     const re = /"([^"]*)"|(\S+)/g;
    373     let m;
    374     while ((m = re.exec(text)) !== null) out.push(m[1] !== undefined ? m[1] : m[2]);
    375     return out;
    376   }
    377 
    378   function fs() { return Module.FS || window.FS; }
    379   function exists(path) { try { return fs().analyzePath(path).exists; } catch (e) { return false; } }
    380 
    381   async function writeUpload(input, dir) {
    382     const file = input.files[0];
    383     if (!file) return null;
    384     const data = new Uint8Array(await file.arrayBuffer());
    385     try { fs().mkdir(dir); } catch (e) {}
    386     const path = dir + '/' + file.name;
    387     fs().writeFile(path, data);
    388     return path;
    389   }
    390 
    391   let keepCram = false;   // lancement de disquette : l'Exelmémoire reste branchée (EXEC y charge les programmes)
    392   function launch(args) {
    393     $('help').hidden = true;
    394     outputEl.textContent = '';
    395     statusEl.textContent = 'dsp ' + args.join(' ');
    396     canvas.style.display = 'block';
    397     // Exelmémoire de l'EXL 100 : le driver la branche si /roms/cros64_exl100.rom existe dans le
    398     // MEMFS ; débranchée, on retire le fichier avant de démarrer (une cartouche formatée
    399     // démarrerait CROS64 à la place du logo). L'EXELTEL l'a toujours (menu Périphériques).
    400     if (!onExeltel && !exelmemEnabled() && !keepCram) {
    401       try { fs().unlink('/roms/cros64_exl100.rom'); } catch (e) {}
    402     }
    403     keepCram = false;
    404     try {
    405       Module.ccall('dsp_web_start', 'number', ['string'], [args.join('\n')]);
    406     } catch (e) {
    407       if (e !== 'unwind' && !(e && e.name === 'ExitStatus')) throw e;
    408     }
    409     canvas.focus();
    410     $('grab').hidden = false;
    411     cramRestore();
    412     cramAutosave();
    413   }
    414 
    415   // Exelmémoire (EXELTEL) : la CRAM (16 Ko, volume CROS64) survit aux rechargements, en
    416   // localStorage (base64), comme le fichier .cram de DCExel. Restaurée dès le lancement de
    417   // la machine (avant que le système ne la lise), sauvée toutes les 2 s si elle a changé,
    418   // et quand la page est masquée ou quittée.
    419   const cramKey = 'exl100.cram.' + gameName;
    420   let cramTimer = null;
    421   function cramRestore() {
    422     try {
    423       if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return;
    424       const b64 = localStorage.getItem(cramKey);
    425       if (b64 && Module.ccall('dsp_web_cram_load', 'number', ['string'], [b64]))
    426         console.log('Exelmémoire restaurée (' + b64.length + ' car.)');
    427       // Message laissé par la bibliothèque juste avant le redémarrage (volume chargé).
    428       try {
    429         const msg = sessionStorage.getItem('exl100.msg');
    430         if (msg) { sessionStorage.removeItem('exl100.msg'); setTimeout(() => { statusEl.textContent = msg; }, 300); }
    431       } catch (e) {}
    432     } catch (e) {}
    433   }
    434   function cramSave(force) {
    435     try {
    436       if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return;
    437       if (!force && !Module.ccall('dsp_web_cram_dirty', 'number', [], [])) return;
    438       const b64 = Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], []));
    439       if (b64) { localStorage.setItem(cramKey, b64); cramRefresh(b64); }
    440     } catch (e) {}
    441   }
    442   function cramAutosave() {
    443     if (cramTimer) clearInterval(cramTimer);
    444     cramTimer = setInterval(() => cramSave(false), 2000);
    445     setTimeout(() => cramRefresh(), 500);
    446   }
    447   document.addEventListener('visibilitychange', () => { if (document.hidden) cramSave(true); });
    448   window.addEventListener('pagehide', () => cramSave(true));
    449 
    450   function fillDrivers(selected) {
    451     const type = typeSel.value;
    452     const list = drivers.filter((d) => type === 'all' || d.type === type)
    453                         .sort((a, b) => a.title.localeCompare(b.title));
    454     driverSel.innerHTML = '';
    455     const head = document.createElement('option');
    456     head.value = '';
    457     head.textContent = '— ' + list.length + ' drivers —';
    458     driverSel.appendChild(head);
    459     for (const d of list) {
    460       const opt = document.createElement('option');
    461       opt.value = d.name;
    462       opt.textContent = d.title + ' (' + d.name + ')';
    463       if (d.aliases.length) opt.title = 'also: ' + d.aliases.join(', ');
    464       driverSel.appendChild(opt);
    465     }
    466     driverSel.value = selected && list.some((d) => d.name === selected) ? selected : '';
    467   }
    468 
    469   // ROM for a driver: the file box, else a preloaded /roms/NAME.zip (or alias).
    470   async function romFor(d) {
    471     const uploaded = await writeUpload(romInput, '/uploads');
    472     if (uploaded) return uploaded;
    473     for (const n of [d.name, ...d.aliases]) {
    474       for (const p of ['/roms/' + n + '.zip', '/roms/' + n]) if (exists(p)) return p;
    475     }
    476     return null;
    477   }
    478 
    479   async function startSelected(openDialog) {
    480     const d = drivers.find((x) => x.name === driverSel.value);
    481     if (!d) { statusEl.textContent = 'Choose a driver.'; return; }
    482     const rom = await romFor(d);
    483     if (!rom) {
    484       statusEl.textContent = 'Choose the ROM for ' + d.title + '.';
    485       if (openDialog) { waitingForRom = true; romInput.click(); }
    486       return;
    487     }
    488     const args = ['--game', d.name, rom];
    489     const media = await writeUpload($('media'), '/uploads');
    490     if (media) args.push($('mediatype').value, media);
    491     args.push(...splitArgs($('extra').value));
    492     launch(args);
    493   }
    494 
    495   typeSel.onchange = () => fillDrivers(driverSel.value);
    496   driverSel.onchange = () => { romInput.value = ''; startSelected(true); };
    497   romInput.onchange = () => { if (waitingForRom || driverSel.value) { waitingForRom = false; startSelected(false); } };
    498   $('start').onclick = () => startSelected(true);
    499   // Clic sur l'écran : l'émulateur prend le clavier (focus), se centre dans la fenêtre, et le dit. Tant qu'il
    500   // a le focus, les touches qui font défiler la page (espace, flèches, Page, Début/Fin, Tab) lui restent.
    501   const grabEl = $('grab');
    502   canvas.onclick = () => {
    503     canvas.focus();
    504     grabEl.hidden = false;
    505     canvas.scrollIntoView({ block: 'center', behavior: 'smooth' });
    506   };
    507   canvas.addEventListener('focus', () => { grabEl.hidden = false; });
    508   canvas.addEventListener('blur', () => { grabEl.hidden = true; });
    509   canvas.addEventListener('keydown', (e) => {
    510     if (e.key === ' ' || e.key === 'Tab' || e.key === 'Home' || e.key === 'End' ||
    511         e.key.startsWith('Arrow') || e.key.startsWith('Page')) e.preventDefault();
    512   }, { capture: true });
    513 
    514   // Liste « Programmes » : cartouches et cassettes embarquées dans dsp.data, chacune avec sa source
    515   // (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo).
    516   const programsEl = $('programs');
    517   const modal = $('modal');
    518   $('pick').onclick = () => { document.body.style.overflow = 'hidden'; modal.showModal(); $('filter').focus(); };
    519   modal.addEventListener('close', () => { document.body.style.overflow = ''; });  // Échap/croix/fond : rend le défilement
    520   $('modal-close').onclick = () => modal.close();
    521   modal.addEventListener('click', (e) => { if (e.target === modal) modal.close(); });  // clic sur le fond ferme
    522   let bootToken = 0; // annule une frappe automatique en cours si on change de programme
    523   function markActive(value) {
    524     let title = '';
    525     for (const el of programsEl.querySelectorAll('.pg')) {
    526       const on = el.dataset.value === value || (!!value && (el.dataset.sides || '').split(' ').includes(value));
    527       el.classList.toggle('active', on);
    528       if (on && value) {
    529         title = el.dataset.title;
    530         // Programme à plusieurs cassettes : la face lancée est surlignée et nommée dans la barre.
    531         for (const sb of el.querySelectorAll('.pg-side')) {
    532           const cur = sb.dataset.value === value;
    533           sb.classList.toggle('on', cur);
    534           if (cur) title += ' — ' + sb.textContent;
    535         }
    536       }
    537     }
    538     $('pick').textContent = title ? '📂 ' + title : '📂 Choisir un programme…';
    539   }
    540   // Cartouche (cart:) = autonome. Cassette BASIC (tape:) = on charge ExelBASIC puis la cassette,
    541   // et la page tape elle-même la séquence de chargement de l'EXL 100 (LOAD"1", Échap, RUN).
    542   function bootProgram(value) {
    543     const token = ++bootToken;
    544     markActive(value);
    545     if (modal.open) modal.close();
    546     const args = ['--game', gameName];
    547     let cassette = false;
    548     if (value.startsWith('cart:')) {
    549       args.push('--tape', '/roms/cartridges/' + value.slice(5));
    550     } else if (value.startsWith('tape:')) {
    551       args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', '/roms/tapes/' + value.slice(5));
    552       cassette = true;
    553       setTape(value.slice(5));
    554     } else if (value.startsWith('disk:')) {
    555       // Disquette de la bibliothèque (EXL 100 seulement) : l'Exeldisk est monté avec l'image (le pilote
    556       // reconnaît .fd) et la machine amorce dessus (BOOT V1.5 puis EXELDOS). L'Exelmémoire reste branchée,
    557       // EXEC (chargeur Jester) y charge les programmes ; si elle a déjà été débranchée dans cette page, ou
    558       // depuis l'EXELTEL, on recharge la page EXL 100 et on relance. Pas de persistance des écritures.
    559       if (onExeltel || !exists('/roms/cros64_exl100.rom')) {
    560         try { sessionStorage.setItem('exl100.exec', value); } catch (e) {}
    561         location.href = onExeltel ? location.pathname : location.href.split('#')[0];
    562         if (!onExeltel) location.reload();
    563         return;
    564       }
    565       const file = value.slice(5);
    566       let data, meta = {};
    567       try {
    568         data = fs().readFile('/roms/disks/' + file);
    569         meta = JSON.parse(fs().readFile('/roms/disks/titles.json', { encoding: 'utf8' }))[file] || {};
    570       } catch (e) { statusEl.textContent = 'Disquette introuvable : ' + file; return; }
    571       setDisk('/roms/disks/' + file, meta.t || file, data);
    572       launchDisk(null);
    573       return;
    574     } else if (value.startsWith('cram:')) {
    575       // Volume Exelmémoire de la bibliothèque. Programmes EXL 100 (ceux de webmo : code machine qui
    576       // appelle ExelBASIC et le moniteur de l'EXL 100) : volume dans l'Exelmémoire, ExelBASIC, puis
    577       // « CALL EXEC(entrée) » tapé automatiquement. Volumes EXELTEL : chargés puis machine redémarrée
    578       // (Périphériques → Exelmémoire ou → Autostart).
    579       const file = value.slice(5);
    580       let data, meta = {};
    581       try {
    582         data = fs().readFile('/roms/cram/' + file);
    583         meta = JSON.parse(fs().readFile('/roms/cram/titles.json', { encoding: 'utf8' }))[file] || {};
    584       } catch (e) { statusEl.textContent = 'Volume introuvable : ' + file; return; }
    585       const machine = meta.machine || 'exl100';
    586       let s = '';
    587       for (let i = 0; i < data.length; i += 4096) s += String.fromCharCode.apply(null, data.subarray(i, i + 4096));
    588       try {
    589         localStorage.setItem('exl100.cram.' + machine, btoa(s));
    590         if (machine === 'exl100') localStorage.setItem('exl100.exelmem', '1');
    591       } catch (e) {}
    592       if (machine === 'exl100' && !onExeltel && exists('/roms/cros64_exl100.rom')) {
    593         // Même page : l'Exelmémoire est encore branchée, on relance la machine directement.
    594         args.push('--tape', '/roms/cartridges/exelbasic.bin', biosZip);
    595         launch(args);
    596         if (meta.entry) autoExec(token, meta.t || file, meta.entry);
    597         else statusEl.textContent = 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire (entrée inconnue : voir CROS, commande DIR).';
    598         return;
    599       }
    600       try {
    601         if (machine === 'exl100') sessionStorage.setItem('exl100.exec', value);
    602         else sessionStorage.setItem('exl100.msg', 'Volume « ' + (meta.t || file) + ' » chargé dans l’Exelmémoire : Périphériques → Exelmémoire (CROS64) ou → Autostart pour le lancer.');
    603       } catch (e) {}
    604       if ((machine === 'exeltel') !== onExeltel) {
    605         location.href = machine === 'exeltel' ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip' : location.pathname;
    606       } else {
    607         location.reload();
    608       }
    609       return;
    610     }
    611     args.push(biosZip);
    612     launch(args);
    613     if (cassette) autoLoadCassette(token);
    614   }
    615 
    616   // Cassette dans la machine et ses autres faces/cassettes (titles.json `sides`) : boutons 📼 de la barre.
    617   let currentTape = null;
    618   function setTape(file, sides) {
    619     if (!sides) {
    620       let m = {};
    621       try { m = JSON.parse(fs().readFile('/roms/tapes/titles.json', { encoding: 'utf8' }))[file] || {}; } catch (e) {}
    622       sides = m.sides || [];
    623     }
    624     currentTape = { file, sides };
    625     const box = $('tape-sides');
    626     box.innerHTML = '';
    627     for (const sd of sides) {
    628       if (sd.f === file) continue;
    629       const b = document.createElement('button'); b.type = 'button'; b.className = 'pick-btn';
    630       b.textContent = '⏏ Insérer ' + sd.l;   // action, pas un état : la face en place est dans le bouton 📂
    631       b.title = 'Insérer « ' + sd.l + ' » sans redémarrer, quand le programme la demande';
    632       b.onclick = () => tapeInsert(sd.f, sd.l);
    633       box.appendChild(b);
    634     }
    635   }
    636   // Change la cassette à chaud : le BIOS lira la nouvelle au prochain chargement (un programme qui réclame
    637   // la face B enchaîne tout seul ; sinon LOAD"1" ⏎ puis Échap dans ExelBASIC).
    638   function tapeInsert(file, label) {
    639     let ok = 0;
    640     try { ok = Module.ccall('dsp_web_tape_load', 'number', ['string'], ['/roms/tapes/' + file]); } catch (e) { ok = 0; }
    641     if (!ok) { statusEl.textContent = 'Impossible d’insérer « ' + label + ' » : lancez d’abord un programme sur cassette.'; return; }
    642     statusEl.textContent = '📼 « ' + label + ' » maintenant dans le lecteur — reprenez le chargement quand le programme le demande (sinon LOAD"1" ⏎ puis Échap).';
    643     markActive('tape:' + file);
    644     const sides = currentTape && currentTape.sides.some((x) => x.f === file) ? currentTape.sides : undefined;
    645     setTape(file, sides);
    646   }
    647   // Frappe synthétique dans ExelBASIC : événements clavier sur le canevas (SDL écoute #canvas). Le " de
    648   // l'EXL 100 = Shift+3, ( = Shift+5, ) = Shift+- ; les modificateurs sont de vraies touches Shift
    649   // maintenues. S'annule si un autre programme est lancé entre-temps (jeton).
    650   function typist(token) {
    651     const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    652     const alive = () => token === bootToken;
    653     const send = (type, code, keyName, shift) =>
    654       canvas.dispatchEvent(new KeyboardEvent(type, { code, key: keyName, shiftKey: !!shift, bubbles: true, cancelable: true }));
    655     const tap = async (code, keyName) => { if (!alive()) return; send('keydown', code, keyName, false); await sleep(70); send('keyup', code, keyName, false); await sleep(110); };
    656     const sh = async (code, shifted) => {
    657       if (!alive()) return;
    658       send('keydown', 'ShiftLeft', 'Shift', true); await sleep(80);
    659       send('keydown', code, shifted, true); await sleep(70); send('keyup', code, shifted, true); await sleep(80);
    660       send('keyup', 'ShiftLeft', 'Shift', false); await sleep(120);
    661     };
    662     const text = async (str) => {
    663       for (const ch of str) {
    664         if (ch === '"') await sh('Digit3', '"');
    665         else if (ch === '(') await sh('Digit5', '(');
    666         else if (ch === ')') await sh('Minus', ')');
    667         else if (ch === ' ') await tap('Space', ' ');
    668         else if (ch === '.') await tap('Period', '.');
    669         else if (/[0-9]/.test(ch)) await tap('Digit' + ch, ch);
    670         else await tap('Key' + ch.toUpperCase(), ch.toLowerCase());
    671       }
    672     };
    673     return { sleep, alive, tap, sh, text };
    674   }
    675 
    676   // Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC.
    677   async function autoLoadCassette(token) {
    678     const { sleep, alive, tap, text } = typist(token);
    679     statusEl.textContent = 'ExelBASIC — chargement automatique de la cassette…';
    680     await sleep(4500); if (!alive()) return;   // ExelBASIC prêt
    681     canvas.focus();
    682     await text('LOAD"1"');
    683     await tap('Enter', 'Enter'); await sleep(1500);
    684     await tap('Escape', 'Escape'); await sleep(7000); if (!alive()) return;   // lecture (chargement rapide)
    685     await text('RUN');
    686     await tap('Enter', 'Enter');
    687     if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN. Certains programmes mettent jusqu’à une minute à s’initialiser.';
    688   }
    689 
    690   // Programme en Exelmémoire : CALL EXEC(entrée) ⏎ dans ExelBASIC.
    691   async function autoExec(token, title, entry) {
    692     const { sleep, alive, tap, text } = typist(token);
    693     statusEl.textContent = 'ExelBASIC — lancement de « ' + title + ' » depuis l’Exelmémoire…';
    694     await sleep(4500); if (!alive()) return;   // ExelBASIC prêt
    695     canvas.focus();
    696     await text('CALL EXEC(' + entry + ')');
    697     await tap('Enter', 'Enter');
    698     if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez CALL EXEC(' + entry + ') puis Entrée.';
    699   }
    700   function sourceOf(m) {
    701     const p = [];
    702     if (m.by) p.push(m.by);
    703     if (m.y) p.push(m.y);
    704     return p.length ? p.join(', ') : 'source inconnue';
    705   }
    706   function addRow(value, title, source, cat, m) {
    707     const b = document.createElement('button');
    708     b.className = 'pg';
    709     b.dataset.value = value;
    710     b.dataset.cat = cat || '';
    711     b.dataset.type = value.startsWith('cart:') ? 'cart' : value.startsWith('tape:') ? 'tape' : value.startsWith('cram:') ? 'cram' : value.startsWith('disk:') ? 'disk' : '';
    712     b.dataset.search = (title + ' ' + source + (m && m.sides ? ' ' + m.sides.map((x) => x.l).join(' ') + ' ' + m.sides.map((x) => titlesCache[x.f] || '').join(' ') : '')).toLowerCase();
    713     b.dataset.title = title;
    714     const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title;
    715     if (m && m.sides && m.sides.length > 1) {
    716       // Programme sur plusieurs faces/cassettes : une seule ligne, chaque face en bouton (démarre dessus) ;
    717       // en cours de route, la suite s'insère avec les boutons 📼 de la barre, sans redémarrer.
    718       b.dataset.sides = m.sides.map((x) => 'tape:' + x.f).join(' ');
    719       for (const sd of m.sides) {
    720         const sb = document.createElement('button'); sb.type = 'button'; sb.className = 'pg-side';
    721         sb.textContent = sd.l;
    722         sb.dataset.value = 'tape:' + sd.f;
    723         sb.title = 'Démarrer sur « ' + sd.l + ' » (en cours de programme, insérez-la plutôt avec le bouton 📼 de la barre)';
    724         sb.onclick = (e) => { e.stopPropagation(); bootProgram('tape:' + sd.f); };
    725         t.appendChild(sb);
    726       }
    727     }
    728     if (value.startsWith('tape:')) {
    729       const ins = document.createElement('button'); ins.type = 'button'; ins.className = 'pg-ins'; ins.textContent = '⏏ insérer';
    730       ins.title = 'Insérer cette cassette dans la machine qui tourne, sans redémarrer (face B, cassette suivante…)';
    731       ins.onclick = (e) => { e.stopPropagation(); tapeInsert(value.slice(5), title); modal.close(); };
    732       t.appendChild(ins);
    733     }
    734     const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source;
    735     b.append(t, s);
    736     b.onclick = () => bootProgram(value);
    737     programsEl.appendChild(b);
    738   }
    739   const titlesCache = {};   // fichier -> titre, pour la recherche sur les faces
    740   function addGroup(label, path, prefix) {
    741     let titles;
    742     try { titles = JSON.parse(fs().readFile(path, { encoding: 'utf8' })); } catch (e) { return; }
    743     for (const [f, m] of Object.entries(titles)) titlesCache[f] = m.t;
    744     const entries = Object.entries(titles).sort((a, b) => a[1].t.localeCompare(b[1].t, 'fr'));
    745     if (!entries.length) return;
    746     const h = document.createElement('div'); h.className = 'pg-group'; h.textContent = label;
    747     programsEl.appendChild(h);
    748     const shown = new Set();
    749     for (const [file, m] of entries) {
    750       if (m.sides && m.sides.length > 1) {
    751         const first = m.sides[0].f;
    752         if (shown.has(first)) continue;   // les autres faces sont sur la même ligne
    753         shown.add(first);
    754         const fm = titles[first] || m;
    755         addRow(prefix + first, m.group || fm.t, sourceOf(fm), fm.cat, fm);
    756         continue;
    757       }
    758       addRow(prefix + file, m.t, sourceOf(m), m.cat, m);
    759     }
    760   }
    761   function populatePrograms() {
    762     programsEl.innerHTML = '';
    763     addRow('', onExeltel ? 'Menu EXELTEL' : 'Logo Exelvision',
    764            onExeltel ? 'terminal télématique, sans programme' : 'aucune cartouche', '');
    765     addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:');
    766     addGroup('Cassettes BASIC', '/roms/tapes/titles.json', 'tape:');
    767     addGroup('Exelmémoire (volumes CROS64)', '/roms/cram/titles.json', 'cram:');
    768     addGroup('Disquettes (Exeldisk, EXELDOS)', '/roms/disks/titles.json', 'disk:');
    769   }
    770 
    771   // Filtres combinés : texte (titre/source) + une puce (type ou catégorie), et masquage des en-têtes vides.
    772   let chip = 'all';
    773   function applyFilter() {
    774     const q = $('filter').value.trim().toLowerCase();
    775     for (const el of programsEl.querySelectorAll('.pg')) {
    776       const okText = q === '' || el.dataset.search.includes(q);
    777       const okChip = chip === 'all' ||
    778         (chip === 'cart' || chip === 'tape' || chip === 'cram' || chip === 'disk' ? el.dataset.type === chip : el.dataset.cat === chip);
    779       el.classList.toggle('hide', !(okText && okChip));
    780     }
    781     let group = null, any = false;
    782     for (const el of programsEl.children) {
    783       if (el.classList.contains('pg-group')) { if (group) group.classList.toggle('hide', !any); group = el; any = false; }
    784       else if (!el.classList.contains('hide')) any = true;
    785     }
    786     if (group) group.classList.toggle('hide', !any);
    787   }
    788   $('filter').oninput = applyFilter;
    789   for (const c of $('chips').querySelectorAll('.chip')) {
    790     c.onclick = () => {
    791       for (const o of $('chips').querySelectorAll('.chip')) o.classList.toggle('active', o === c);
    792       chip = c.dataset.f;
    793       applyFilter();
    794     };
    795   }
    796 
    797   // Sélecteur de machine : EXL 100 (défaut) ou EXELTEL, le terminal télématique à 64 Ko de la même
    798   // famille (même driver exelv). On recharge la page avec ?game=exeltel. La bibliothèque de
    799   // programmes reste offerte (l'EXELTEL lit les cartouches/cassettes EXL 100) ; sans programme choisi,
    800   // l'EXELTEL démarre sur son menu télématique.
    801   const machineSel = $('machine');
    802   if (onExeltel) {
    803     machineSel.value = 'exeltel';
    804     document.querySelector('header .sub').textContent =
    805       "L'EXELTEL (1986), le terminal télématique de la même famille, à processeur TMS7000 et 64 Ko : " +
    806       "Minitel intégré, répondeur et téléphone. Sans programme choisi, son menu s'affiche (mode local, " +
    807       "sans ligne) ; il lit aussi les cartouches et cassettes EXL 100 ci-dessous.";
    808     document.querySelector('header h1').innerHTML =
    809       '<span class="logo">EXEL<span>TEL</span></span> dans le navigateur';
    810   }
    811   // Interrupteur Exelmémoire (EXL 100 seulement ; l'EXELTEL l'a toujours) : paramètre ?exelmem=1|0,
    812   // sinon préférence du navigateur, défaut débranchée (le logo reste l'accueil). Changer = recharger.
    813   const exelmemBox = $('exelmem');
    814   function exelmemEnabled() {
    815     if (onExeltel) return true;
    816     const p = new URLSearchParams(location.search).get('exelmem');
    817     if (p !== null) return p === '1';
    818     try { return localStorage.getItem('exl100.exelmem') === '1'; } catch (e) { return false; }
    819   }
    820   exelmemBox.checked = exelmemEnabled();
    821   exelmemBox.disabled = onExeltel;
    822   $('exelmem-note').textContent = onExeltel
    823     ? 'Toujours branchée sur l’EXELTEL (menu Périphériques → Exelmémoire).'
    824     : 'Branchée, l’EXL 100 démarre sur le gestionnaire CROS au lieu du logo. Changer recharge la page.';
    825   exelmemBox.onchange = () => {
    826     try { localStorage.setItem('exl100.exelmem', exelmemBox.checked ? '1' : '0'); } catch (e) {}
    827     const u = new URL(location.href);
    828     u.searchParams.delete('exelmem');
    829     location.href = u.toString();
    830   };
    831   // Modale Exelmémoire : un seul bouton de la barre regroupe brancher/débrancher, contenu du volume,
    832   // import/export et vider. Import / export au format .cram de DCExel : 16 Ko bruts ($8000-$BFFF) pour
    833   // l'EXL 100 ; pour l'EXELTEL DCExel écrit une image de 64 Ko de tout l'espace d'adresses.
    834   const cramModal = $('cram-modal');
    835   $('cram-open').onclick = () => { cramRefresh(); cramModal.showModal(); };
    836   $('cram-modal-close').onclick = () => cramModal.close();
    837   cramModal.addEventListener('click', (e) => { if (e.target === cramModal) cramModal.close(); });
    838   function cramBytes() {
    839     const s = atob(Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], [])));
    840     const a = new Uint8Array(s.length);
    841     for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
    842     return a;
    843   }
    844   $('cram-export').onclick = () => {
    845     try {
    846       if (!Module.ccall('dsp_web_cram_present', 'number', [], [])) return;
    847       const vol = cramBytes();
    848       let out = vol;
    849       if (onExeltel) { out = new Uint8Array(65536); out.set(vol, 0x8000); }
    850       const a = document.createElement('a');
    851       a.href = URL.createObjectURL(new Blob([out], { type: 'application/octet-stream' }));
    852       a.download = 'exelmemoire-' + gameName + '.cram';
    853       a.click();
    854       setTimeout(() => URL.revokeObjectURL(a.href), 2000);
    855     } catch (e) { statusEl.textContent = 'Export .cram impossible : ' + e; }
    856   };
    857   // Fichier de l'utilisateur : une seule zone (clic ou dépôt, aussi sur l'écran), type détecté par
    858   // l'extension puis le contenu, puis lancé comme il faut : cassette = ExelBASIC + auto-chargement,
    859   // cartouche = démarrage direct, image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel, .bkp webmo = en-tête
    860   // de 512 octets puis 64 blocs de 258 octets, 16 Ko à l'envers) = volume chargé puis rechargement,
    861   // disquette .fd = Exeldisk monté (--tape x.fd, le pilote reconnaît l'extension) et amorçage dessus.
    862   const CART_SIZES = new Set([8192, 16384, 32256, 32768]);
    863   function detectMedia(name, data) {
    864     const ext = (name.toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || '';
    865     const n = data.length;
    866     const isWav = n > 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46;
    867     const leader = n > 64 && data.subarray(0, 32).every((b) => b === 0x55);
    868     const bkp = n === 17408 && data[0] === 0xfd;
    869     const cramCtl = n === 16384 && (data[0x3fe1] === 0xaa || (data[0] === 0xaa && data[1] === 0x8c));
    870     if (ext === 'wav' || isWav) return { kind: 'cassette', why: 'enregistrement .wav' };
    871     if (ext === 'k7' || leader) return { kind: 'cassette', why: 'cassette .k7' };
    872     if (ext === 'bkp' || bkp) return { kind: 'exelmemoire', why: 'sauvegarde BKP', bkp: true };
    873     if (ext === 'cram' || n === 65536 || cramCtl) return { kind: 'exelmemoire', why: n === 65536 ? 'image 64 Ko' : 'image .cram 16 Ko' };
    874     if (ext === 'fd' || ext === 'td0' || (n > 2 && data[0] === 0x54 && data[1] === 0x44)) return { kind: 'disquette', why: ext === 'td0' ? 'image Teledisk' : 'image de disquette' };
    875     if (['bin', 'rom', 'cart'].includes(ext) || CART_SIZES.has(n)) return { kind: 'cartouche', why: n + ' octets' };
    876     return { kind: 'inconnu', why: n + ' octets' };
    877   }
    878   async function writeMedia(file, data) {
    879     try { fs().mkdir('/uploads'); } catch (e) {}
    880     const path = '/uploads/' + file.name.replace(/[^\w.-]+/g, '_');
    881     fs().writeFile(path, data);
    882     return path;
    883   }
    884   // Répertoire EXELDOS d'une image .fd : AU de 512 octets, répertoire à l'AU 1 (en-tête $D0 $0D, entrées de
    885   // 18 octets = nom 13 + extension 3 + AU du FDR 2, chaînage par les 2 derniers octets de l'AU, fin si > $7FFF).
    886   // S'il y a des fichiers .BAS, la disquette veut la cartouche ExelBASIC (LOADRUN, APPLI.BAS, INIBAS).
    887   function diskNeedsBasic(data) {
    888     try {
    889       let au = 1;
    890       for (let n = 0; n < 64 && au > 0 && au < 0x500; n++) {
    891         const o = au * 512;
    892         if (data[o] !== 0xd0 || data[o + 1] !== 0x0d) break;
    893         for (let e = o + 2; e + 18 <= o + 510; e += 18) {
    894           if (data[e] === 0 || data[e] === 0xff) continue;
    895           if (String.fromCharCode(data[e + 13], data[e + 14], data[e + 15]) === 'BAS') return true;
    896         }
    897         au = (data[o + 510] << 8) | data[o + 511];
    898       }
    899     } catch (e) {}
    900     return false;
    901   }
    902   // Contenu d'une image .fd : même répertoire, plus le FDR de chaque fichier (AU×512 : $FD, drapeaux, nombre
    903   // d'enregistrements, longueur d'enregistrement, chaîne d'AU dès +13 : AU hi/lo, nombre).
    904   function parseFd(data) {
    905     const files = [];
    906     let au = 1;
    907     for (let n = 0; n < 64 && au > 0 && au < 0x500; n++) {
    908       const o = au * 512;
    909       if (data[o] !== 0xd0 || data[o + 1] !== 0x0d) break;
    910       for (let e = o + 2; e + 18 <= o + 510; e += 18) {
    911         if (data[e] === 0 || data[e] === 0xff || data[e] === 0x20) continue;
    912         const name = String.fromCharCode.apply(null, data.subarray(e, e + 13)).replace(/[^ -~]/g, ' ').trim();
    913         const ext = String.fromCharCode(data[e + 13], data[e + 14], data[e + 15]).replace(/[^ -~]/g, ' ').trim();
    914         const fau = (data[e + 16] << 8) | data[e + 17];
    915         let size = 0, aus = 0;
    916         const f = fau * 512;
    917         if (f + 256 <= data.length && data[f] === 0xfd) {
    918           size = ((data[f + 2] << 8) | data[f + 3]) * ((data[f + 4] << 8) | data[f + 5]);
    919           for (let i = 0; i < 81; i++) { if (!((data[f + 13 + 3 * i] << 8) | data[f + 14 + 3 * i])) break; aus += data[f + 15 + 3 * i]; }
    920         }
    921         if (name) files.push({ name, ext, au: fau, size, aus: aus + 1 });
    922       }
    923       au = (data[o + 510] << 8) | data[o + 511];
    924     }
    925     return files;
    926   }
    927   // Disquette montée : image, titre, cartouche BASIC nécessaire ; modale de son contenu, lancement d'un fichier.
    928   let currentDisk = null;
    929   const diskModal = $('disk-modal');
    930   function diskCommand(f) {
    931     if (f.ext === 'BAS') return { cmd: 'LOADRUN ' + f.name + '.BAS' };
    932     if (f.ext === 'BIN') return { cmd: 'EXEC ' + f.name, confirm: 'O' };   // chargeur Jester : « Écraser exelmémoire ? »
    933     if (f.ext === 'OBJ') return { cmd: f.name };
    934     return null;
    935   }
    936   function setDisk(path, title, data) {
    937     currentDisk = { path, title, data, basic: diskNeedsBasic(data), files: parseFd(data) };
    938     $('disk-open').hidden = false;
    939     $('disk-title').textContent = 'Disquette « ' + title + ' »';
    940     $('disk-summary').textContent = currentDisk.files.length + ' fichier' + (currentDisk.files.length > 1 ? 's' : '') +
    941       (currentDisk.basic ? ' — programmes BASIC : cartouche ExelBASIC insérée' : '');
    942     const tb = $('disk-files').querySelector('tbody');
    943     tb.innerHTML = '';
    944     for (const f of currentDisk.files) {
    945       const tr = document.createElement('tr');
    946       const run = diskCommand(f);
    947       for (const [val, cls] of [[f.name, ''], [f.ext, ''], [f.size.toLocaleString('fr-FR'), 'num'], [f.aus, 'num'], [run ? '▶ ' + run.cmd : '', 'go']]) {
    948         const td = document.createElement('td'); td.textContent = val; if (cls) td.className = cls; tr.appendChild(td);
    949       }
    950       if (run) { tr.className = 'run'; tr.title = 'Lancer ' + f.name; tr.onclick = () => launchDisk(f); }
    951       tb.appendChild(tr);
    952     }
    953   }
    954   // Redémarre sur la disquette (ExelBASIC si besoin, Exelmémoire gardée) ; avec un fichier, tape sa commande
    955   // à l'invite EXELDOS une fois le DOS chargé.
    956   function launchDisk(f) {
    957     if (!currentDisk) return;
    958     const token = ++bootToken;
    959     markActive(f ? '' : 'disk:' + currentDisk.path.split('/').pop());
    960     if (modal.open) modal.close();
    961     if (diskModal.open) diskModal.close();
    962     keepCram = true;
    963     const args = ['--game', gameName];
    964     if (currentDisk.basic) args.push('--tape', '/roms/cartridges/exelbasic.bin');
    965     args.push('--tape', currentDisk.path, biosZip);
    966     launch(args);
    967     if (f) autoDos(token, f, diskCommand(f));
    968     else { statusEl.textContent = 'Amorçage sur « ' + currentDisk.title + ' »… le bouton 💽 Disquette liste son contenu.'; setTimeout(() => { if (token === bootToken) diskModal.showModal(); }, 400); }
    969   }
    970   async function autoDos(token, f, run) {
    971     const { sleep, alive, tap, text } = typist(token);
    972     statusEl.textContent = 'EXELDOS — lancement de ' + f.name + '.' + f.ext + '…';
    973     await sleep(3500); if (!alive()) return;   // BOOT V1.5 puis invite [A]
    974     canvas.focus();
    975     await text(run.cmd);
    976     await tap('Enter', 'Enter');
    977     if (run.confirm) { await sleep(2500); if (!alive()) return; await text(run.confirm); }
    978     if (alive()) statusEl.textContent = 'Si rien ne démarre : à l’invite [A], tapez ' + run.cmd + (run.confirm ? ' puis répondez ' + run.confirm : '') + '.';
    979   }
    980   $('disk-open').onclick = () => diskModal.showModal();
    981   $('disk-modal-close').onclick = () => diskModal.close();
    982   diskModal.addEventListener('click', (e) => { if (e.target === diskModal) diskModal.close(); });
    983   async function openMedia(file) {
    984     if (!file) return;
    985     const data = new Uint8Array(await file.arrayBuffer());
    986     const d = detectMedia(file.name, data);
    987     const label = { cassette: 'Cassette', cartouche: 'Cartouche', exelmemoire: 'Exelmémoire', disquette: 'Disquette', inconnu: 'Fichier' }[d.kind];
    988     if (d.kind === 'disquette' && d.why === 'image Teledisk') {
    989       statusEl.textContent = label + ' détectée (' + d.why + ') : les images Teledisk (.td0) ne sont pas encore lues ; convertissez-la en .fd.';
    990       return;
    991     }
    992     if (d.kind === 'disquette' && data.length !== 655360) {
    993       statusEl.textContent = label + ' inattendue (' + data.length + ' octets) : une image .fd Exeldisk fait 655 360 octets (80 pistes, 2 faces, 16 secteurs de 256).';
    994       return;
    995     }
    996     if (d.kind === 'inconnu') {
    997       statusEl.textContent = 'Type de fichier non reconnu (' + d.why + ') : cassette .k7/.wav, cartouche .bin/.rom, Exelmémoire .cram/.bkp.';
    998       return;
    999     }
   1000     if (d.kind === 'exelmemoire') {
   1001       let vol = data;
   1002       if (d.bkp) {
   1003         // 64 blocs de 258 octets ($01 $00 + 256 octets) à partir de $200, CRAM à l'envers ($BFFF d'abord).
   1004         const fwd = new Uint8Array(16384);
   1005         for (let b = 0; b < 64; b++) fwd.set(data.subarray(0x200 + b * 258 + 2, 0x200 + b * 258 + 258), b * 256);
   1006         vol = new Uint8Array(16384); for (let i = 0; i < 16384; i++) vol[i] = fwd[16383 - i];
   1007       }
   1008       else if (data.length === 65536) vol = data.subarray(0x8000, 0xc000);
   1009       if (vol.length !== 16384) { statusEl.textContent = 'Image Exelmémoire inattendue (' + data.length + ' octets).'; return; }
   1010       let s = '';
   1011       for (let i = 0; i < vol.length; i += 4096) s += String.fromCharCode.apply(null, vol.subarray(i, i + 4096));
   1012       try { localStorage.setItem(cramKey, btoa(s)); if (!onExeltel) localStorage.setItem('exl100.exelmem', '1'); } catch (e) {}
   1013       statusEl.textContent = label + ' détectée (' + d.why + ') : volume chargé, redémarrage…';
   1014       location.reload();
   1015       return;
   1016     }
   1017     const token = ++bootToken;
   1018     markActive('');
   1019     const path = await writeMedia(file, data);
   1020     const args = ['--game', gameName];
   1021     if (d.kind === 'cassette') args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', path);
   1022     else if (d.kind === 'disquette') { setDisk(path, file.name, data); launchDisk(null); $('pick').textContent = '📄 ' + file.name; return; }
   1023     else args.push('--tape', path);
   1024     args.push(biosZip);
   1025     launch(args);
   1026     statusEl.textContent = label + ' détectée (' + d.why + ') : ' + (d.kind === 'cassette' ? 'chargement dans ExelBASIC…'
   1027       : d.kind === 'disquette' ? 'amorçage sur l’Exeldisk (la disquette doit porter EXELDOS)…' : 'démarrage…');
   1028     $('pick').textContent = '📄 ' + file.name;
   1029     if (d.kind === 'cassette') autoLoadCassette(token);
   1030   }
   1031   $('media').onchange = () => openMedia($('media').files[0]);
   1032   for (const el of [$('drop'), canvas]) {
   1033     el.addEventListener('dragover', (e) => { e.preventDefault(); $('drop').classList.add('over'); });
   1034     el.addEventListener('dragleave', () => $('drop').classList.remove('over'));
   1035     el.addEventListener('drop', (e) => { e.preventDefault(); $('drop').classList.remove('over'); openMedia(e.dataTransfer.files[0]); });
   1036   }
   1037   // Explorateur de l'Exelmémoire. Format CROS64 (vérifié sur les volumes webmo et DCExel) : nom du
   1038   // volume en $BFD1-$BFDC, $BFE1 = $AA si formaté, bas du répertoire en $BFDF:$BFE0, début de la zone
   1039   // libre en $BFE3:$BFE4 (libres = bas - zone libre - 5). Les fichiers sont des enregistrements contigus
   1040   // en descendant depuis $BFD0 : type/drapeaux (2), $FFFC (2), C (2, taille = $10006 - C), longueur
   1041   // d'enregistrement (2), longueur du nom (1), nom à l'envers, 2 octets, extension à l'envers (3),
   1042   // commentaire puis données.
   1043   function cramParse(v) {
   1044     const at = (a) => v[a - 0x8000];
   1045     const clean = (s) => s.replace(/[^ -~]/g, ' ').trim();
   1046     const name = clean(String.fromCharCode.apply(null, v.subarray(0x3fd1, 0x3fdd)));
   1047     const formatted = at(0xbfe1) === 0xaa;
   1048     const dirLo = (at(0xbfdf) << 8) | at(0xbfe0), freeLo = (at(0xbfe3) << 8) | at(0xbfe4);
   1049     const free = formatted ? Math.max(0, Math.min(16327, dirLo - freeLo - 5)) : 0;
   1050     const files = [];
   1051     for (let p = 0xbfd0; formatted && p > dirLo && p <= 0xbfd0 && files.length < 64;) {
   1052       const b = (o) => at(p - o);
   1053       const flags = b(0) | (b(1) << 8), c = b(4) | (b(5) << 8), rclen = b(6) | (b(7) << 8), n = b(8);
   1054       const size = 0x10006 - c;
   1055       if (size < 12 || size > 16384 || n > 12 || p - size < 0x8000) break;
   1056       let nm = '', ext = '', com = '';
   1057       for (let i = 0; i < n; i++) nm += String.fromCharCode(b(9 + i));
   1058       for (let i = 0; i < 3; i++) ext += String.fromCharCode(b(13 + n - i));   // à l'envers en mémoire
   1059       for (let i = 0; i < Math.min(24, size - 14 - n); i++) com += String.fromCharCode(b(14 + n + i));
   1060       files.push({ name: clean(nm), ext: clean(ext), size, rclen, flags, comment: clean(com) });
   1061       p -= size;
   1062     }
   1063     return { name, formatted, free, files };
   1064   }
   1065   function cramRefresh(b64) {
   1066     const present = (() => { try { return Module.ccall('dsp_web_cram_present', 'number', [], []); } catch (e) { return 0; } })();
   1067     const io = [$('cram-export'), $('cram-clear')];
   1068     const tb = $('cram-files').querySelector('tbody');
   1069     tb.innerHTML = '';
   1070     if (!present) {
   1071       $('cram-summary').textContent = onExeltel ? 'Exelmémoire indisponible.' : 'Exelmémoire débranchée — cochez ci-dessus pour l’activer.';
   1072       $('cram-files').hidden = true; $('cram-empty').textContent = '';
   1073       for (const b of io) b.disabled = true;
   1074       return;
   1075     }
   1076     for (const b of io) b.disabled = false;
   1077     try {
   1078       if (!b64) b64 = Module.UTF8ToString(Module.ccall('dsp_web_cram_save', 'number', [], []));
   1079       const s = atob(b64), v = new Uint8Array(s.length);
   1080       for (let i = 0; i < s.length; i++) v[i] = s.charCodeAt(i);
   1081       const info = cramParse(v);
   1082       $('cram-summary').textContent = info.formatted
   1083         ? 'Volume « ' + (info.name || 'sans nom') + ' » — ' + info.files.length + ' fichier' + (info.files.length > 1 ? 's' : '') +
   1084           ', ' + info.free.toLocaleString('fr-FR') + ' octets libres'
   1085         : 'Volume non formaté (gestionnaire CROS : Format / FMT).';
   1086       for (const f of info.files) {
   1087         const tr = document.createElement('tr');
   1088         for (const [val, cls] of [[f.name, ''], [f.ext, ''], [f.size, 'num'], [f.rclen, 'num'],
   1089                                   [f.flags.toString(16).toUpperCase().padStart(4, '0'), ''], [f.comment, '']]) {
   1090           const td = document.createElement('td'); td.textContent = val; if (cls) td.className = cls; tr.appendChild(td);
   1091         }
   1092         tb.appendChild(tr);
   1093       }
   1094       $('cram-files').hidden = !info.files.length;
   1095       $('cram-empty').textContent = info.formatted ? (info.files.length ? '' : 'Volume formaté, aucun fichier.') :
   1096         'Le volume doit être formaté avant usage (menu CROS64 : Format ; CROS 16K : FMT).';
   1097     } catch (e) { $('cram-summary').textContent = 'Exelmémoire (contenu illisible).'; }
   1098   }
   1099   $('cram-clear').onclick = () => {
   1100     if (!confirm('Vider l’Exelmémoire ? Le contenu actuel sera perdu (exportez-le d’abord si besoin).')) return;
   1101     try { localStorage.removeItem(cramKey); } catch (e) {}
   1102     location.reload();
   1103   };
   1104   $('cram-import').onclick = () => $('cram-file').click();
   1105   // Même détection que le dépôt (gère .cram 16 Ko, image 64 Ko DCExel et sauvegarde .bkp webmo).
   1106   $('cram-file').onchange = () => { if ($('cram-file').files[0]) openMedia($('cram-file').files[0]); };
   1107   machineSel.onchange = () => {
   1108     location.href = machineSel.value === 'exeltel'
   1109       ? location.pathname + '?game=exeltel&rom=/roms/exeltel.zip'
   1110       : location.pathname;
   1111   };
   1112 
   1113   // Déblocage du son : le contexte Web Audio ouvert par SDL démarre « suspended » (politique
   1114   // d'autoplay des navigateurs) ; sans geste utilisateur, aucun son ne sort (parole, Exeldrums…).
   1115   // On le reprend à chaque geste réel (clic/touche), tant qu'il est suspendu — le contexte peut
   1116   // n'exister qu'après le démarrage d'un programme, d'où des écouteurs persistants.
   1117   function resumeAudio() {
   1118     try {
   1119       const sdl = window.Module && window.Module.SDL2;
   1120       const c = sdl && sdl.audioContext;
   1121       if (c && c.state === 'suspended') c.resume().catch(() => {});
   1122     } catch (e) { /* pas prêt */ }
   1123   }
   1124   for (const ev of ['pointerdown', 'mousedown', 'touchstart', 'keydown', 'click']) {
   1125     document.addEventListener(ev, resumeAudio, { capture: true, passive: true });
   1126   }
   1127 
   1128   const Module = window.Module = {
   1129     canvas: canvas,
   1130     print: (text) => { console.log(text); log(text); },
   1131     printErr: (text) => { console.warn(text); log(text); },
   1132     onRuntimeInitialized: () => {
   1133       drivers = JSON.parse(Module.UTF8ToString(Module.ccall('dsp_web_list', 'number', [], [])));
   1134       const params = new URLSearchParams(location.search);
   1135       let args = [];
   1136       if (params.get('game')) {
   1137         args = ['--game', params.get('game')];
   1138         if (params.get('rom')) args.push(params.get('rom'));
   1139         if (params.get('args')) args.push(...splitArgs(params.get('args')));
   1140       } else if (DSP_DEFAULT_ARGS.trim() !== '') {
   1141         args = splitArgs(DSP_DEFAULT_ARGS);
   1142       }
   1143       const game = args[args.indexOf('--game') + 1];
   1144       const d = drivers.find((x) => x.name === game || x.aliases.includes(game));
   1145       if (d) typeSel.value = d.type;
   1146       fillDrivers(d ? d.name : '');
   1147       statusEl.textContent = drivers.length + ' drivers';
   1148       populatePrograms();
   1149       markActive('');  // le démarrage par défaut, c'est le logo
   1150       let pending = null;
   1151       try { pending = sessionStorage.getItem('exl100.exec'); if (pending) sessionStorage.removeItem('exl100.exec'); } catch (e) {}
   1152       if (pending && !onExeltel) bootProgram(pending);   // programme Exelmémoire choisi avant le rechargement
   1153       else if (args.length > 0) launch(args);
   1154       else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]);  // media built in
   1155     },
   1156   };
   1157   window.onerror = () => { statusEl.textContent = 'Error, see the log below.'; };
   1158 })();
   1159 </script>
   1160 {{{ SCRIPT }}}
   1161 </body>
   1162 </html>