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>