exl100

Émulateur Exelvision EXL 100 (1984) dans le navigateur : dsp-cpp en WebAssembly, cartouches et cassettes BASIC
git clone https://git.schurger.org/exl100.git
Log | Files | Refs | README

commit 796b91dd4c63661b937b21b1576816086c86e573
parent 995e15de01d77f234fd83e900c96f7f1b8ecb7f4
Author: Jean Schurger <jean@schurger.org>
Date:   Wed,  4 Nov 2026 17:09:14 -0500

Liste de programmes avec source (éditeur, année) au lieu d'un menu déroulant

tools/get_cartridges.py extrait l'éditeur et l'année du nom TOSEC (titles.json devient
{t,by,y}). La page remplace le <select> par une liste cliquable, groupée (Cartouches /
Cassettes BASIC), chaque entrée montrant son titre et sa source, avec un filtre texte et
la surbrillance de l'actif. 107 entrées.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9

Diffstat:
Mtools/get_cartridges.py | 31+++++++++++++++++++++++--------
Mweb/shell.html.in | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 100 insertions(+), 26 deletions(-)

diff --git a/tools/get_cartridges.py b/tools/get_cartridges.py @@ -24,12 +24,27 @@ TOSEC_URL = ("https://ia600708.us.archive.org/6/items/pack-roms-exelvision-exl-1 HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OUT = os.path.join(HERE, "roms", "cartridges") -# Un titre lisible depuis un nom TOSEC « ExelBASIC+ (198x)(Exelvision)(FR) » -> « ExelBASIC+ ». +# Titre, éditeur et année depuis un nom TOSEC « Tennis (198x)(Exelvision)(FR) ». +# Le 1er groupe (...) est l'année, le 2e l'éditeur, le reste le pays/flags. def title_of(name): base = os.path.splitext(os.path.basename(name))[0] base = re.split(r"\s*\(", base, maxsplit=1)[0].strip() return base or os.path.splitext(os.path.basename(name))[0] +def meta_of(name): + base = os.path.splitext(os.path.basename(name))[0] + title = title_of(name) + year, by = "", "" + for g in re.findall(r"\(([^)]*)\)", base): + g = g.strip() + if not g or g == "-": + continue + if not year and re.match(r"(19|20)[0-9x]{2}", g): + year = g + elif not by and g.lower() not in ("fr", "us", "eu", "en", "de", "es") and not re.fullmatch(r"[A-Za-z]{2}", g): + by = g + return title, by, year + def slug_of(title): s = title.lower().replace("+", "-plus").replace("é", "e").replace("è", "e") s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") @@ -58,16 +73,16 @@ def main(): if not members: continue data = z.read(members[0]) - title = title_of(fn) + title, by, year = meta_of(fn) slug = slug_of(title) out = f"{slug}.bin" with open(os.path.join(OUT, out), "wb") as f: f.write(data) - titles[out] = title - print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title}", + titles[out] = {"t": title, "by": by, "y": year} + print(f" {out:22s} {len(data):6d} o crc={zlib.crc32(data) & 0xffffffff:08x} {title} — {by} {year}", file=sys.stderr) with open(os.path.join(OUT, "titles.json"), "w", encoding="utf-8") as f: - json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f, + json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f, ensure_ascii=False, indent=1) print(f"{len(titles)} cartouches dans {OUT}", file=sys.stderr) extract_tapes(tmp) @@ -97,20 +112,20 @@ def extract_tapes(tmp, limit=120): data = z.read(members[0]) if not data: continue - title = title_of(fn) + title, by, year = meta_of(fn) slug = slug_of(title) out = f"{slug}.k7" if out in titles: # doublon de titre : on garde le premier continue with open(os.path.join(TAPES_OUT, out), "wb") as f: f.write(data) - titles[out] = title + titles[out] = {"t": title, "by": by, "y": year} if len(titles) >= limit: break if len(titles) >= limit: break with open(os.path.join(TAPES_OUT, "titles.json"), "w", encoding="utf-8") as f: - json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1].lower())), f, + json.dump(dict(sorted(titles.items(), key=lambda kv: kv[1]["t"].lower())), f, ensure_ascii=False, indent=1) print(f"{len(titles)} cassettes dans {TAPES_OUT}", file=sys.stderr) diff --git a/web/shell.html.in b/web/shell.html.in @@ -55,6 +55,27 @@ .toolbar input[type=file] { max-width: 230px; } .hidden { display: none !important; } + /* Liste des programmes (avec leur source), à la place d'un menu déroulant */ + .picker { max-width: 900px; margin: 0 auto 16px; } + .picker-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; + flex-wrap: wrap; margin: 0 0 6px; color: var(--muted); font-size: .9rem; } + .picker-head small { color: #7e7b74; } + #filter { background: #2b2d35; color: #fff; border: 1px solid #40434e; border-radius: 6px; + padding: 6px 10px; font: inherit; font-size: .9rem; min-width: 180px; } + .programs { max-height: 260px; overflow-y: auto; border: 1px solid #2a2c33; border-radius: 8px; + background: #17181c; } + .pg-group { position: sticky; top: 0; background: #1b1d22; color: var(--cyan); font-size: .78rem; + letter-spacing: .04em; text-transform: uppercase; padding: 6px 12px; border-bottom: 1px solid #2a2c33; } + .pg { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; width: 100%; + text-align: left; background: none; border: 0; border-bottom: 1px solid #202228; color: var(--fg); + padding: 7px 12px; font: inherit; cursor: pointer; } + .pg:hover { background: #22242b; } + .pg.active { background: var(--accent); color: #fff; } + .pg.active .pg-s { color: #ffd6e2; } + .pg-t { font-size: .95rem; } + .pg-s { color: var(--muted); font-size: .8rem; white-space: nowrap; flex-shrink: 0; } + .pg.hide, .pg-group.hide { display: none; } + /* Journal (replié) */ #output { max-width: 900px; margin: 8px auto 0; white-space: pre-wrap; color: #7e7b74; font: 11px ui-monospace, monospace; max-height: 16vh; overflow: auto; } @@ -90,10 +111,7 @@ </label> <label class="hidden">Driver <select id="driver"><option value="">…</option></select></label> <label class="hidden">ROM <input id="rom" type="file"></label> - <label>Programme - <select id="program"><option value="">— Logo Exelvision (aucune cartouche) —</option></select> - </label> - <label>Charger un fichier + <label>Charger votre fichier <select id="mediatype"> <option value="--tape">cartouche / cassette</option> <option value="--cart">cartouche (--cart)</option> @@ -106,6 +124,14 @@ <details id="more" class="hidden"><summary>options</summary></details> </div> + <section class="picker"> + <div class="picker-head"> + <span>Programmes <small>cartouches et cassettes BASIC, avec leur source</small></span> + <input id="filter" type="search" placeholder="Filtrer…" autocomplete="off"> + </div> + <div id="programs" class="programs" role="listbox" aria-label="Programmes"></div> + </section> + <section class="screen"> <div class="bezel"><canvas id="canvas" tabindex="0" oncontextmenu="event.preventDefault()"></canvas></div> <div id="status">Chargement…</div> @@ -122,7 +148,8 @@ <p>Au lancement, la machine affiche le papillon arc-en-ciel et le logo <b>exelvision</b> sur fond cyan, exactement comme un EXL 100 à la mise sous tension. L'EXL 100 n'a pas de BASIC intégré : comme sur la vraie machine, tout vient d'une <b>cartouche</b> ou d'une <b>cassette</b>.</p> - <p>Choisissez un titre dans le menu <b>Programme</b>. Les <b>Cartouches</b> (ExelBASIC, Tennis, Guppy, + <p>Cliquez un titre dans la <b>liste des programmes</b> (chaque entrée indique sa source : éditeur + et année) ; un filtre aide à s'y retrouver. Les <b>Cartouches</b> (ExelBASIC, Tennis, Guppy, Virus, Exelpaint…) démarrent seules. Les <b>Cassettes (BASIC)</b> — une cinquantaine de jeux et programmes — <b>se chargent toutes seules</b> : la page lance ExelBASIC puis tape <code>LOAD "1"</code>, <kbd>Échap</kbd> et <code>RUN</code> pour vous ; patientez quelques secondes. @@ -259,14 +286,18 @@ $('start').onclick = () => startSelected(true); canvas.onclick = () => canvas.focus(); - // Menu « Programme » : cartouches embarquées dans dsp.data (/roms/cartridges/), lues par leur - // titre dans titles.json. On relance l'EXL 100 avec la cartouche choisie (ou le logo seul). - const programSel = $('program'); + // Liste « Programmes » : cartouches et cassettes embarquées dans dsp.data, chacune avec sa source + // (éditeur, année) lue dans titles.json. On relance l'EXL 100 avec le programme choisi (ou le logo). + const programsEl = $('programs'); let bootToken = 0; // annule une frappe automatique en cours si on change de programme + function markActive(value) { + for (const el of programsEl.querySelectorAll('.pg')) el.classList.toggle('active', el.dataset.value === value); + } // Cartouche (cart:) = autonome. Cassette BASIC (tape:) = on charge ExelBASIC puis la cassette, // et la page tape elle-même la séquence de chargement de l'EXL 100 (LOAD"1", Échap, RUN). function bootProgram(value) { const token = ++bootToken; + markActive(value); const args = ['--game', 'exl100']; let cassette = false; if (value.startsWith('cart:')) { @@ -279,7 +310,6 @@ launch(args); if (cassette) autoLoadCassette(token); } - programSel.onchange = () => bootProgram(programSel.value); // Tape LOAD"1" ⏎, Échap, puis RUN ⏎ dans ExelBASIC, en événements clavier synthétiques sur le // canevas (SDL écoute #canvas). Le " de l'EXL 100 = Shift+3 ; les modificateurs sont de vraies @@ -307,22 +337,50 @@ await tap('Enter', 'Enter'); if (alive()) statusEl.textContent = 'Si rien ne démarre : au curseur, tapez LOAD "1" (Entrée), Échap, puis RUN.'; } + function sourceOf(m) { + const p = []; + if (m.by) p.push(m.by); + if (m.y) p.push(m.y); + return p.length ? p.join(', ') : 'source inconnue'; + } + function addRow(value, title, source) { + const b = document.createElement('button'); + b.className = 'pg'; + b.dataset.value = value; + b.dataset.search = (title + ' ' + source).toLowerCase(); + const t = document.createElement('span'); t.className = 'pg-t'; t.textContent = title; + const s = document.createElement('span'); s.className = 'pg-s'; s.textContent = source; + b.append(t, s); + b.onclick = () => bootProgram(value); + programsEl.appendChild(b); + } function addGroup(label, path, prefix) { let titles; try { titles = JSON.parse(fs().readFile(path, { encoding: 'utf8' })); } catch (e) { return; } - const g = document.createElement('optgroup'); - g.label = label; - for (const [file, title] of Object.entries(titles).sort((a, b) => a[1].localeCompare(b[1], 'fr'))) { - const opt = document.createElement('option'); - opt.value = prefix + file; opt.textContent = title; - g.appendChild(opt); - } - if (g.children.length) programSel.appendChild(g); + const entries = Object.entries(titles).sort((a, b) => a[1].t.localeCompare(b[1].t, 'fr')); + if (!entries.length) return; + const h = document.createElement('div'); h.className = 'pg-group'; h.textContent = label; + programsEl.appendChild(h); + for (const [file, m] of entries) addRow(prefix + file, m.t, sourceOf(m)); } function populatePrograms() { + programsEl.innerHTML = ''; + addRow('', 'Logo Exelvision', 'aucune cartouche'); addGroup('Cartouches', '/roms/cartridges/titles.json', 'cart:'); - addGroup('Cassettes (BASIC)', '/roms/tapes/titles.json', 'tape:'); + addGroup('Cassettes BASIC', '/roms/tapes/titles.json', 'tape:'); } + // Filtre texte : masque les lignes qui ne correspondent pas, et les en-têtes vides. + $('filter').oninput = (e) => { + const q = e.target.value.trim().toLowerCase(); + for (const el of programsEl.querySelectorAll('.pg')) + el.classList.toggle('hide', q !== '' && !el.dataset.search.includes(q)); + let group = null, any = false; + for (const el of programsEl.children) { + if (el.classList.contains('pg-group')) { if (group) group.classList.toggle('hide', !any); group = el; any = false; } + else if (!el.classList.contains('hide')) any = true; + } + if (group) group.classList.toggle('hide', !any); + }; const Module = window.Module = { canvas: canvas, @@ -345,6 +403,7 @@ fillDrivers(d ? d.name : ''); statusEl.textContent = drivers.length + ' drivers'; populatePrograms(); + markActive(''); // le démarrage par défaut, c'est le logo if (args.length > 0) launch(args); else if (Module.ccall('dsp_web_has_embedded', 'number', [], [])) launch([]); // media built in },