From 36ac7b0cd6d71487a75b276a0a1a606bcda0514d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:13:52 +0200 Subject: [PATCH] feat: split the heavy site pages A manufacturer page over 500 KB of Markdown becomes a summary: its largest consoles move to pages of their own, cut into parts when one console is still too large, and every console keeps its heading there. MAME's 1,564 cards move to four pages, its page keeping a row per entry with the card's old anchor. RetroDECK's arcade list gets its own page. site.js follows a moved card by content or by its row. The largest page drops from 10.5 MB to 0.88 MB. Emulator groups of the navigation open by initial (65 KB to 41 KB before the content). File cards are level 3 under a level 2, and the hook keeps the table of contents of card pages to level 2: platform pages no longer list every system id. Key/value cards name their columns. The sweep runs before the decoration, which kept every page no generator wrote any more. --- docs_assets/site.js | 16 +- mkdocs.yml | 1188 +++++++++++++++++++------------------ scripts/generate_site.py | 627 +++++++++++++++----- scripts/mkdocs_hooks.py | 20 + scripts/siterender.py | 66 ++- tests/test_site_render.py | 4 +- tests/test_site_weight.py | 168 ++++++ wiki/testing-guide.md | 2 +- 8 files changed, 1362 insertions(+), 729 deletions(-) create mode 100644 tests/test_site_weight.py diff --git a/docs_assets/site.js b/docs_assets/site.js index aacaaa24..33548400 100644 --- a/docs_assets/site.js +++ b/docs_assets/site.js @@ -238,10 +238,20 @@ }; // A file card moved to another page keeps its address: an id of the form - // sha1-<12 hex> that this page does not hold is looked up by content. + // sha1-<12 hex> that this page does not hold is looked up by content, and + // an emulator card listed in the table of a split page opens its page. const followMovedCard = () => { - const match = /^#sha1-([0-9a-f]{12})$/.exec(window.location.hash); - if (!match || document.getElementById(`sha1-${match[1]}`)) return; + const id = decodeURIComponent(window.location.hash.slice(1)); + if (!id) return; + const target = document.getElementById(id); + const row = target?.closest(".rb-card-index tr"); + const link = row?.querySelector("a[href]"); + if (link) { + window.location.replace(link.href); + return; + } + const match = /^sha1-([0-9a-f]{12})$/.exec(id); + if (!match || target) return; window.location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot())); }; diff --git a/mkdocs.yml b/mkdocs.yml index 2bbc5ee9..f38f336f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,19 +84,17 @@ markdown_extensions: - tables - toc: permalink: true - # File cards are level 4 headings: the search lands on them, and a - # page of a hundred cards keeps a readable table of contents. + # Level 3 is the wiki's subsections. On pages of file cards, also level + # 3, the hook keeps the table of contents to level 2. toc_depth: 3 - pymdownx.details - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.keys -- pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format +# No mermaid fence: Material drew it in the browser with a script from a +# CDN. Diagrams are SVG files written by scripts/sitediagrams.py. +- pymdownx.superfences - pymdownx.tabbed: alternate_style: true plugins: @@ -245,587 +243,613 @@ nav: - 2600.emu: emulators/2600-emu.md - 3dSen: emulators/3dsen.md - A to C: - - a5200: emulators/a5200.md - - A7800: emulators/a7800.md - - Abuse: emulators/abuse.md - - ACE-DL: emulators/ace-dl.md - - advanced_drastic: emulators/advanced_drastic.md - - AdvanceMAME: emulators/advancemame.md - - AdvanceMAME 0.94: emulators/advmame-0.94.md - - AdvanceMAME 1.4: emulators/advmame-1.4.md - - Adventure Game Studio: emulators/ags.md - - AetherSX2: emulators/aethersx2.md - - Aleph One: emulators/alephone.md - - Altirra: emulators/altirra.md - - amiarcadia: emulators/amiarcadia.md - - Amiberry: emulators/amiberry.md - - AppleWin: emulators/applewin.md - - aPS3e: emulators/aps3e.md - - ArcadeDuck: emulators/arcadeduck.md - - ArcadeFlashWeb: emulators/arcadeflashweb.md - - Ardens: emulators/ardens.md - - Arduous: emulators/arduous.md - - ares: emulators/ares.md - - ARMSX1: emulators/armsx1.md - - ARMSX2: emulators/armsx2.md - - Atari800: emulators/atari800.md - - aX360e: emulators/ax360e.md - - Azahar: emulators/azahar.md - - AzaharPlus: emulators/azaharplus.md - - b2: emulators/b2.md - - Basilisk II: emulators/basiliskii.md - - BBKEmu: emulators/bbkemu.md - - beebem: emulators/beebem.md - - Beetle GBA (Mednafen): emulators/beetle_gba.md - - Beetle Lynx (Mednafen Lynx): emulators/beetle_lynx.md - - Beetle NGP (Mednafen Neo Geo Pocket): emulators/beetle_ngp.md - - Beetle PC-FX (Mednafen): emulators/beetle_pcfx.md - - Beetle PCE (Mednafen PCE): emulators/beetle_pce.md - - Beetle PSX (Mednafen PSX): emulators/beetle_psx.md - - Beetle VB (Mednafen Virtual Boy): emulators/beetle_vb.md - - Beetle WonderSwan (Mednafen WonderSwan): emulators/beetle_wswan.md - - beetle_saturn: emulators/beetle_saturn.md - - BennuGD: emulators/bennugd.md - - BennuGD monolithic (bgdi): emulators/bennugd-monolithic.md - - Bermuda Syndrome: emulators/bsyndrome.md - - Bermuda Syndrome (bs): emulators/bermuda.md - - BigPEmu: emulators/bigpemu.md - - BizHawk: emulators/bizhawk.md - - bk-emulator: emulators/bk.md - - BlastEm: emulators/blastem.md - - blueMSX: emulators/bluemsx.md - - bnes: emulators/bnes.md - - boom3: emulators/boom3.md - - Boytacean: emulators/boytacean.md - - bsnes: emulators/bsnes.md - - bsnes 2014: emulators/bsnes2014.md - - bsnes C++98: emulators/bsnes_cplusplus98.md - - bsnes-hd beta: emulators/bsnes_hd_beta.md - - bsnes-jg: emulators/bsnes-jg.md - - bsnes-mercury: emulators/bsnes_mercury.md - - BStone: emulators/bstone.md - - C-Dogs SDL: emulators/cdogs.md - - C64.emu: emulators/c64-emu.md - - Cannonball: emulators/cannonball.md - - CaPriCe Forever: emulators/capriceforever.md - - Caprice32: emulators/cap32.md - - CapriceRPI: emulators/capricerpi.md - - CatacombGL: emulators/catacombgl.md - - ccleste: emulators/ccleste.md - - CDi 2015: emulators/cdi2015.md - - Cemu: emulators/cemu.md - - Chihiro-DS: emulators/chihiro-ds.md - - ChimeraSNES: emulators/chimerasnes.md - - Chocolate Doom: emulators/chocolate-doom.md - - Citra: emulators/citra.md - - Citra 2018: emulators/citra2018.md - - Citra Canary: emulators/citra_canary.md - - Citra MMJ: emulators/citra-mmj.md - - Citron: emulators/citron.md - - Clock Signal (CLK): emulators/clk.md - - ClownMDEmu: emulators/clownmdemu.md - - ColEm: emulators/colem.md - - Commander Genius: emulators/cgenius.md - - CoolbarEmu: emulators/coolbaremu.md - - CoolCV: emulators/coolcv.md - - corsixth: emulators/corsixth.md - - CPCemu: emulators/cpcemu.md - - CrocoDS: emulators/crocods.md - - Cruzes: emulators/cruzes.md - - CSpect: emulators/cspect.md - - Cxbx-Reloaded: emulators/cxbx-reloaded.md - - Cxbx-Reloaded (31ff15b): emulators/cxbx-reloaded-2023.md + - A: + - a5200: emulators/a5200.md + - A7800: emulators/a7800.md + - Abuse: emulators/abuse.md + - ACE-DL: emulators/ace-dl.md + - advanced_drastic: emulators/advanced_drastic.md + - AdvanceMAME: emulators/advancemame.md + - AdvanceMAME 0.94: emulators/advmame-0.94.md + - AdvanceMAME 1.4: emulators/advmame-1.4.md + - Adventure Game Studio: emulators/ags.md + - AetherSX2: emulators/aethersx2.md + - Aleph One: emulators/alephone.md + - Altirra: emulators/altirra.md + - amiarcadia: emulators/amiarcadia.md + - Amiberry: emulators/amiberry.md + - AppleWin: emulators/applewin.md + - aPS3e: emulators/aps3e.md + - ArcadeDuck: emulators/arcadeduck.md + - ArcadeFlashWeb: emulators/arcadeflashweb.md + - Ardens: emulators/ardens.md + - Arduous: emulators/arduous.md + - ares: emulators/ares.md + - ARMSX1: emulators/armsx1.md + - ARMSX2: emulators/armsx2.md + - Atari800: emulators/atari800.md + - aX360e: emulators/ax360e.md + - Azahar: emulators/azahar.md + - AzaharPlus: emulators/azaharplus.md + - B: + - b2: emulators/b2.md + - Basilisk II: emulators/basiliskii.md + - BBKEmu: emulators/bbkemu.md + - beebem: emulators/beebem.md + - Beetle GBA (Mednafen): emulators/beetle_gba.md + - Beetle Lynx (Mednafen Lynx): emulators/beetle_lynx.md + - Beetle NGP (Mednafen Neo Geo Pocket): emulators/beetle_ngp.md + - Beetle PC-FX (Mednafen): emulators/beetle_pcfx.md + - Beetle PCE (Mednafen PCE): emulators/beetle_pce.md + - Beetle PSX (Mednafen PSX): emulators/beetle_psx.md + - Beetle VB (Mednafen Virtual Boy): emulators/beetle_vb.md + - Beetle WonderSwan (Mednafen WonderSwan): emulators/beetle_wswan.md + - beetle_saturn: emulators/beetle_saturn.md + - BennuGD: emulators/bennugd.md + - BennuGD monolithic (bgdi): emulators/bennugd-monolithic.md + - Bermuda Syndrome: emulators/bsyndrome.md + - Bermuda Syndrome (bs): emulators/bermuda.md + - BigPEmu: emulators/bigpemu.md + - BizHawk: emulators/bizhawk.md + - bk-emulator: emulators/bk.md + - BlastEm: emulators/blastem.md + - blueMSX: emulators/bluemsx.md + - bnes: emulators/bnes.md + - boom3: emulators/boom3.md + - Boytacean: emulators/boytacean.md + - bsnes: emulators/bsnes.md + - bsnes 2014: emulators/bsnes2014.md + - bsnes C++98: emulators/bsnes_cplusplus98.md + - bsnes-hd beta: emulators/bsnes_hd_beta.md + - bsnes-jg: emulators/bsnes-jg.md + - bsnes-mercury: emulators/bsnes_mercury.md + - BStone: emulators/bstone.md + - C: + - C-Dogs SDL: emulators/cdogs.md + - C64.emu: emulators/c64-emu.md + - Cannonball: emulators/cannonball.md + - CaPriCe Forever: emulators/capriceforever.md + - Caprice32: emulators/cap32.md + - CapriceRPI: emulators/capricerpi.md + - CatacombGL: emulators/catacombgl.md + - ccleste: emulators/ccleste.md + - CDi 2015: emulators/cdi2015.md + - Cemu: emulators/cemu.md + - Chihiro-DS: emulators/chihiro-ds.md + - ChimeraSNES: emulators/chimerasnes.md + - Chocolate Doom: emulators/chocolate-doom.md + - Citra: emulators/citra.md + - Citra 2018: emulators/citra2018.md + - Citra Canary: emulators/citra_canary.md + - Citra MMJ: emulators/citra-mmj.md + - Citron: emulators/citron.md + - Clock Signal (CLK): emulators/clk.md + - ClownMDEmu: emulators/clownmdemu.md + - ColEm: emulators/colem.md + - Commander Genius: emulators/cgenius.md + - CoolbarEmu: emulators/coolbaremu.md + - CoolCV: emulators/coolcv.md + - corsixth: emulators/corsixth.md + - CPCemu: emulators/cpcemu.md + - CrocoDS: emulators/crocods.md + - Cruzes: emulators/cruzes.md + - CSpect: emulators/cspect.md + - Cxbx-Reloaded: emulators/cxbx-reloaded.md + - Cxbx-Reloaded (31ff15b): emulators/cxbx-reloaded-2023.md - D to F: - - D.Smile: emulators/dsmile.md - - DaedalusX64: emulators/daedalusx64.md - - Daphne: emulators/daphne.md - - DarkPlaces: emulators/darkplaces.md - - Demul: emulators/demul.md - - DeSmuME: emulators/desmume.md - - DeSmuME 2015: emulators/desmume2015.md - - devilutionx: emulators/devilutionx.md - - DGen/SDL: emulators/dgen.md - - dhewm3: emulators/dhewm3.md - - DICE: emulators/dice.md - - DingooEmu: emulators/dingooemu.md - - Dinothawr: emulators/dinothawr.md - - DirectXBox: emulators/directxbox.md - - DirkSimple: emulators/dirksimple.md - - Dolphin: emulators/dolphin.md - - Dolphin Launcher: emulators/dolphin_launcher.md - - Dolphin MMJR: emulators/dolphin-mmjr.md - - Dolphin MMJR2: emulators/dolphin-mmjr2.md - - DOSBox: emulators/dosbox.md - - DOSBox (SDL2 fork): emulators/dosbox-sdl2.md - - DOSBox Pure: emulators/dosbox_pure.md - - DOSBox Pure Unleashed: emulators/dosbox-pure-unleashed.md - - DOSBox Staging: emulators/dosbox-staging.md - - DOSBox-core: emulators/dosbox_core.md - - DOSBox-SVN: emulators/dosbox_svn.md - - DOSBox-SVN CE: emulators/dosbox_svn_ce.md - - DOSBox-X: emulators/dosbox-x.md - - DoubleCherryGB: emulators/doublecherrygb.md - - doukutsu-rs: emulators/doukutsu_rs.md - - DraStic: emulators/drastic.md - - DREAMM: emulators/dreamm.md - - DroidArcadia: emulators/droidarcadia.md - - dsda-doom: emulators/dsda-doom.md - - DSperate: emulators/dsperate.md - - DuckStation: emulators/duckstation.md - - DuckStation (legacy): emulators/duckstation-legacy.md - - Dusklight: emulators/dusklight.md - - dxx-rebirth: emulators/dxx-rebirth.md - - EasyRPG Player: emulators/easyrpg.md - - ECWolf: emulators/ecwolf.md - - Eden: emulators/eden.md - - EDuke32: emulators/eduke32.md - - EightyOne: emulators/81.md - - EightyOne (Recalbox): emulators/eightyone.md - - eka2l1: emulators/eka2l1.md - - EmuCoreV: emulators/emucorev.md - - EmuCoreX: emulators/emucorex.md - - EmuCV: emulators/emucv.md - - EmuSCV: emulators/emuscv.md - - emux (CHIP-8): emulators/emux_chip8.md - - emux (Game Boy): emulators/emux_gb.md - - emux (NES): emulators/emux_nes.md - - emux (SMS): emulators/emux_sms.md - - ep128emu-core: emulators/ep128emu.md - - ep128emu_core: emulators/ep128emu_core.md - - ePSXe: emulators/epsxe.md - - ET Legacy: emulators/etlegacy.md - - Eternal Computer: emulators/eternal.md - - Exel-Hence: emulators/exel-hence.md - - FAKE-08: emulators/fake08.md - - fallout1-ce: emulators/fallout1-ce.md - - fallout2-ce: emulators/fallout2-ce.md - - FB Alpha 2012: emulators/fbalpha2012.md - - FB Alpha 2012 CPS-1: emulators/fbalpha2012_cps1.md - - FB Alpha 2012 CPS-2: emulators/fbalpha2012_cps2.md - - FB Alpha 2012 CPS-3: emulators/fbalpha2012_cps3.md - - FB Alpha 2012 Neo Geo: emulators/fbalpha2012_neogeo.md - - FB Alpha 2019: emulators/fbalpha2019.md - - FBZX: emulators/fbzx.md - - FCEUmm: emulators/fceumm.md - - FCEUmm Mod: emulators/fceumm_mod.md - - fheroes2: emulators/fheroes2.md - - FinalBurn Neo: emulators/fbneo.md - - FinalBurn Neo (CPS-1/CPS-2): emulators/fbneo_cps12.md - - FinalBurn Neo (Neo Geo): emulators/fbneo_neogeo.md - - fixGB: emulators/fixgb.md - - fixNES: emulators/fixnes.md - - Flycast: emulators/flycast.md - - Flycast 2021: emulators/flycast2021.md - - Flycast 2022 Low-End: emulators/flycast2021le.md - - Flycast Dojo: emulators/flycast-dojo.md - - fMSX: emulators/fmsx.md - - FPse: emulators/fpse.md - - FPseNG: emulators/fpse-ng.md - - FreeChaF: emulators/freechaf.md - - FreeIntv: emulators/freeintv.md - - FreeJ2ME: emulators/freej2me.md - - FreeJ2ME-Plus: emulators/freej2me_plus.md - - Frodo: emulators/frodo.md - - Frotz: emulators/frotz.md - - FS-UAE: emulators/fsuae.md - - FS-UAE 3.1: emulators/fs-uae.md - - Fuse: emulators/fuse.md - - future-pinball: emulators/future-pinball.md - - future-pinball-fploader: emulators/future-pinball-fploader.md + - D: + - D.Smile: emulators/dsmile.md + - DaedalusX64: emulators/daedalusx64.md + - Daphne: emulators/daphne.md + - DarkPlaces: emulators/darkplaces.md + - Demul: emulators/demul.md + - DeSmuME: emulators/desmume.md + - DeSmuME 2015: emulators/desmume2015.md + - devilutionx: emulators/devilutionx.md + - DGen/SDL: emulators/dgen.md + - dhewm3: emulators/dhewm3.md + - DICE: emulators/dice.md + - DingooEmu: emulators/dingooemu.md + - Dinothawr: emulators/dinothawr.md + - DirectXBox: emulators/directxbox.md + - DirkSimple: emulators/dirksimple.md + - Dolphin: emulators/dolphin.md + - Dolphin Launcher: emulators/dolphin_launcher.md + - Dolphin MMJR: emulators/dolphin-mmjr.md + - Dolphin MMJR2: emulators/dolphin-mmjr2.md + - DOSBox: emulators/dosbox.md + - DOSBox (SDL2 fork): emulators/dosbox-sdl2.md + - DOSBox Pure: emulators/dosbox_pure.md + - DOSBox Pure Unleashed: emulators/dosbox-pure-unleashed.md + - DOSBox Staging: emulators/dosbox-staging.md + - DOSBox-core: emulators/dosbox_core.md + - DOSBox-SVN: emulators/dosbox_svn.md + - DOSBox-SVN CE: emulators/dosbox_svn_ce.md + - DOSBox-X: emulators/dosbox-x.md + - DoubleCherryGB: emulators/doublecherrygb.md + - doukutsu-rs: emulators/doukutsu_rs.md + - DraStic: emulators/drastic.md + - DREAMM: emulators/dreamm.md + - DroidArcadia: emulators/droidarcadia.md + - dsda-doom: emulators/dsda-doom.md + - DSperate: emulators/dsperate.md + - DuckStation: emulators/duckstation.md + - DuckStation (legacy): emulators/duckstation-legacy.md + - Dusklight: emulators/dusklight.md + - dxx-rebirth: emulators/dxx-rebirth.md + - E: + - EasyRPG Player: emulators/easyrpg.md + - ECWolf: emulators/ecwolf.md + - Eden: emulators/eden.md + - EDuke32: emulators/eduke32.md + - EightyOne: emulators/81.md + - EightyOne (Recalbox): emulators/eightyone.md + - eka2l1: emulators/eka2l1.md + - EmuCoreV: emulators/emucorev.md + - EmuCoreX: emulators/emucorex.md + - EmuCV: emulators/emucv.md + - EmuSCV: emulators/emuscv.md + - emux (CHIP-8): emulators/emux_chip8.md + - emux (Game Boy): emulators/emux_gb.md + - emux (NES): emulators/emux_nes.md + - emux (SMS): emulators/emux_sms.md + - ep128emu-core: emulators/ep128emu.md + - ep128emu_core: emulators/ep128emu_core.md + - ePSXe: emulators/epsxe.md + - ET Legacy: emulators/etlegacy.md + - Eternal Computer: emulators/eternal.md + - Exel-Hence: emulators/exel-hence.md + - F: + - FAKE-08: emulators/fake08.md + - fallout1-ce: emulators/fallout1-ce.md + - fallout2-ce: emulators/fallout2-ce.md + - FB Alpha 2012: emulators/fbalpha2012.md + - FB Alpha 2012 CPS-1: emulators/fbalpha2012_cps1.md + - FB Alpha 2012 CPS-2: emulators/fbalpha2012_cps2.md + - FB Alpha 2012 CPS-3: emulators/fbalpha2012_cps3.md + - FB Alpha 2012 Neo Geo: emulators/fbalpha2012_neogeo.md + - FB Alpha 2019: emulators/fbalpha2019.md + - FBZX: emulators/fbzx.md + - FCEUmm: emulators/fceumm.md + - FCEUmm Mod: emulators/fceumm_mod.md + - fheroes2: emulators/fheroes2.md + - FinalBurn Neo: emulators/fbneo.md + - FinalBurn Neo (CPS-1/CPS-2): emulators/fbneo_cps12.md + - FinalBurn Neo (Neo Geo): emulators/fbneo_neogeo.md + - fixGB: emulators/fixgb.md + - fixNES: emulators/fixnes.md + - Flycast: emulators/flycast.md + - Flycast 2021: emulators/flycast2021.md + - Flycast 2022 Low-End: emulators/flycast2021le.md + - Flycast Dojo: emulators/flycast-dojo.md + - fMSX: emulators/fmsx.md + - FPse: emulators/fpse.md + - FPseNG: emulators/fpse-ng.md + - FreeChaF: emulators/freechaf.md + - FreeIntv: emulators/freeintv.md + - FreeJ2ME: emulators/freej2me.md + - FreeJ2ME-Plus: emulators/freej2me_plus.md + - Frodo: emulators/frodo.md + - Frotz: emulators/frotz.md + - FS-UAE: emulators/fsuae.md + - FS-UAE 3.1: emulators/fs-uae.md + - Fuse: emulators/fuse.md + - future-pinball: emulators/future-pinball.md + - future-pinball-fploader: emulators/future-pinball-fploader.md - G to L: - - galaksija: emulators/galaksija.md - - GAM4980: emulators/gam4980.md - - Gambatte: emulators/gambatte.md - - Game & Watch: emulators/gw.md - - GameTank: emulators/gametank.md - - Gargoyle: emulators/gargoyle.md - - GBA.emu: emulators/gba-emu.md - - GBC.emu: emulators/gbc-emu.md - - Gearboy: emulators/gearboy.md - - Gearcoleco: emulators/gearcoleco.md - - Geargrafx: emulators/geargrafx.md - - Gearlynx: emulators/gearlynx.md - - Gearsystem: emulators/gearsystem.md - - Geartowns: emulators/geartowns.md - - GemRB: emulators/gemrb.md - - Genesis Plus GX: emulators/genesis_plus_gx.md - - Genesis Plus GX Expanded: emulators/genesis_plus_gx_expanded.md - - Genesis Plus GX Wide: emulators/genesis_plus_gx_wide.md - - Geolith: emulators/geolith.md - - Ghostship: emulators/ghostship.md - - GnGeo-Pi: emulators/gngeopi.md - - Gopher2600: emulators/gopher2600.md - - gopher64: emulators/gopher64.md - - gpSP: emulators/gpsp.md - - GroovyMAME: emulators/groovymame.md - - GSplus: emulators/gsplus.md - - GSplus (legacy): emulators/gsplus-legacy.md - - GZDoom: emulators/gzdoom.md - - hakuX: emulators/hakux.md - - Handy: emulators/handy.md - - Hatari: emulators/hatari.md - - Hatari 2014: emulators/hatari2014.md - - hatariB: emulators/hatarib.md - - HBMAME (Homebrew MAME): emulators/hbmame.md - - higan (SFC Accuracy): emulators/higan_sfc.md - - hode: emulators/hode.md - - Holani: emulators/holani.md - - Hurrican: emulators/hurrican.md - - Hydra Castle Labyrinth: emulators/hcl.md - - Hypseus Singe: emulators/hypseus-singe.md - - idTech4A++: emulators/idtech4a++.md - - Ikemen GO: emulators/ikemen.md - - iNES: emulators/ines.md - - Infinity: emulators/infinity.md - - Ion Fury: emulators/fury.md - - ioquake3: emulators/ioquake3.md - - ioRTCW: emulators/iortcw.md - - IrataJaguar: emulators/iratajaguar.md - - IroGB: emulators/irogb.md - - Ishiiruka: emulators/ishiiruka.md - - izapple2: emulators/izapple2.md - - J2ME Loader: emulators/j2me-loader.md - - JAXE: emulators/jaxe.md - - Jazz² Resurrection: emulators/jazz2-native.md - - jgenesis: emulators/jgenesis.md - - JL-Mod: emulators/jl-mod.md - - JollyCV: emulators/jollycv.md - - Julius: emulators/julius.md - - Jynx: emulators/jynx.md - - jzIntv: emulators/jzintv.md - - Kega Fusion: emulators/kega-fusion.md - - KEGS: emulators/kegs.md - - KEmulator nnmod: emulators/kemulator.md - - Kenji-NX: emulators/kenji-nx.md - - Kronos: emulators/kronos.md - - Lexaloffle: emulators/lexaloffle.md - - lightspark: emulators/lightspark.md - - Lime3DS: emulators/lime3ds.md - - LinApple: emulators/linapple.md - - Lindbergh Loader: emulators/lindbergh-loader.md - - Linkboy: emulators/linkboy.md - - Linux Loader: emulators/linuxloader.md - - LowRes NX: emulators/lowresnx.md - - LRPS2: emulators/lrps2.md - - Lynx.emu: emulators/lynx-emu.md - - LZDoom: emulators/lzdoom.md + - G: + - galaksija: emulators/galaksija.md + - GAM4980: emulators/gam4980.md + - Gambatte: emulators/gambatte.md + - Game & Watch: emulators/gw.md + - GameTank: emulators/gametank.md + - Gargoyle: emulators/gargoyle.md + - GBA.emu: emulators/gba-emu.md + - GBC.emu: emulators/gbc-emu.md + - Gearboy: emulators/gearboy.md + - Gearcoleco: emulators/gearcoleco.md + - Geargrafx: emulators/geargrafx.md + - Gearlynx: emulators/gearlynx.md + - Gearsystem: emulators/gearsystem.md + - Geartowns: emulators/geartowns.md + - GemRB: emulators/gemrb.md + - Genesis Plus GX: emulators/genesis_plus_gx.md + - Genesis Plus GX Expanded: emulators/genesis_plus_gx_expanded.md + - Genesis Plus GX Wide: emulators/genesis_plus_gx_wide.md + - Geolith: emulators/geolith.md + - Ghostship: emulators/ghostship.md + - GnGeo-Pi: emulators/gngeopi.md + - Gopher2600: emulators/gopher2600.md + - gopher64: emulators/gopher64.md + - gpSP: emulators/gpsp.md + - GroovyMAME: emulators/groovymame.md + - GSplus: emulators/gsplus.md + - GSplus (legacy): emulators/gsplus-legacy.md + - GZDoom: emulators/gzdoom.md + - H: + - hakuX: emulators/hakux.md + - Handy: emulators/handy.md + - Hatari: emulators/hatari.md + - Hatari 2014: emulators/hatari2014.md + - hatariB: emulators/hatarib.md + - HBMAME (Homebrew MAME): emulators/hbmame.md + - higan (SFC Accuracy): emulators/higan_sfc.md + - hode: emulators/hode.md + - Holani: emulators/holani.md + - Hurrican: emulators/hurrican.md + - Hydra Castle Labyrinth: emulators/hcl.md + - Hypseus Singe: emulators/hypseus-singe.md + - I: + - idTech4A++: emulators/idtech4a++.md + - Ikemen GO: emulators/ikemen.md + - iNES: emulators/ines.md + - Infinity: emulators/infinity.md + - Ion Fury: emulators/fury.md + - ioquake3: emulators/ioquake3.md + - ioRTCW: emulators/iortcw.md + - IrataJaguar: emulators/iratajaguar.md + - IroGB: emulators/irogb.md + - Ishiiruka: emulators/ishiiruka.md + - izapple2: emulators/izapple2.md + - J: + - J2ME Loader: emulators/j2me-loader.md + - JAXE: emulators/jaxe.md + - Jazz² Resurrection: emulators/jazz2-native.md + - jgenesis: emulators/jgenesis.md + - JL-Mod: emulators/jl-mod.md + - JollyCV: emulators/jollycv.md + - Julius: emulators/julius.md + - Jynx: emulators/jynx.md + - jzIntv: emulators/jzintv.md + - K: + - Kega Fusion: emulators/kega-fusion.md + - KEGS: emulators/kegs.md + - KEmulator nnmod: emulators/kemulator.md + - Kenji-NX: emulators/kenji-nx.md + - Kronos: emulators/kronos.md + - L: + - Lexaloffle: emulators/lexaloffle.md + - lightspark: emulators/lightspark.md + - Lime3DS: emulators/lime3ds.md + - LinApple: emulators/linapple.md + - Lindbergh Loader: emulators/lindbergh-loader.md + - Linkboy: emulators/linkboy.md + - Linux Loader: emulators/linuxloader.md + - LowRes NX: emulators/lowresnx.md + - LRPS2: emulators/lrps2.md + - Lynx.emu: emulators/lynx-emu.md + - LZDoom: emulators/lzdoom.md - M to O: - - M2000: emulators/m2000.md - - M64Plus FZ: emulators/m64plus-fz.md - - MagicEngine: emulators/magicengine.md - - MAME: emulators/mame.md - - MAME 0.258: emulators/mame0258.md - - MAME 0.278: emulators/mame0278.md - - MAME 2000: emulators/mame2000.md - - MAME 2003: emulators/mame2003.md - - MAME 2003 Midway: emulators/mame2003_midway.md - - MAME 2003-Plus: emulators/mame2003_plus.md - - MAME 2009: emulators/mame2009.md - - MAME 2010: emulators/mame2010.md - - MAME 2014: emulators/mame2014.md - - MAME 2015: emulators/mame2015.md - - MAME 2016: emulators/mame2016.md - - MAME Arcade: emulators/mamearcade.md - - MAME MESS: emulators/mamemess.md - - MAME4ALL-Pi: emulators/mame4all.md - - MAME4droid (0.139u1): emulators/mame4droid.md - - MAME4droid 2024: emulators/mame4droid-2024.md - - Mandarine: emulators/mandarine.md - - Mariani: emulators/mariani.md - - MasterGear: emulators/mastergear.md - - MD.emu: emulators/md-emu.md - - Mednafen: emulators/mednafen.md - - Mednafen STV: emulators/mednafen_stv.md - - mednafen_gba: emulators/mednafen_gba.md - - mednafen_pce: emulators/mednafen_pce.md - - mednafen_pce_fast: emulators/mednafen_pce_fast.md - - mednafen_snes: emulators/mednafen_snes.md - - mednafen_supafaust: emulators/mednafen_supafaust.md - - mednafen_supergrafx: emulators/mednafen_supergrafx.md - - melonDS: emulators/melonds.md - - melonDS DS: emulators/melonds_ds.md - - melonDS Nightly: emulators/melonds-nightly.md - - Mesen: emulators/mesen.md - - mesen-s: emulators/mesen-s.md - - MesenCE: emulators/mesence.md - - MESS 2015: emulators/mess2015.md - - Meteor GBA: emulators/meteor.md - - MFME: emulators/mfme.md - - mGBA: emulators/mgba.md - - mGBA Splitscreen: emulators/mgba_splitscreen.md - - Mini vMac: emulators/minivmac.md - - Mini vMac 37: emulators/minivmac37.md - - mkxp-z: emulators/mkxp_z.md - - Model 2 Emulator: emulators/model2.md - - MojoZork: emulators/mojozork.md - - MSX.emu: emulators/msx-emu.md - - Mu: emulators/mu.md - - Mupen64Plus: emulators/mupen64plus.md - - Mupen64Plus (libretro, 2019): emulators/mupen64plus-libretro.md - - Mupen64Plus AE: emulators/mupen64plus-ae.md - - Mupen64Plus-Next: emulators/mupen64plus_next.md - - My Boy!: emulators/my-boy.md - - My OldBoy!: emulators/my-oldboy.md - - NanoBoyAdvance: emulators/nanoboyadvance.md - - Native32Emu: emulators/native32emu.md - - nekop2: emulators/nekop2.md - - NEO.emu: emulators/neo-emu.md - - NeoCD: emulators/neocd.md - - nes: emulators/nes.md - - NES.emu: emulators/nes-emu.md - - Nesoid: emulators/nesoid.md - - Nestopia UE: emulators/nestopia.md - - NetherSX2: emulators/nethersx2.md - - NetherSX2-Turnip: emulators/nethersx2-turnip.md - - NetherSX2-Turnip Classic: emulators/nethersx2-turnip-classic.md - - NGP.emu: emulators/ngp-emu.md - - NicaiEmu: emulators/nicaiemu.md - - no$gba: emulators/nosgba.md - - NooDS: emulators/noods.md - - NP2kai: emulators/np2kai.md - - np2pi: emulators/np2pi.md - - nSide (SFC Balanced): emulators/higan_sfc_balanced.md - - Nuance: emulators/nuance.md - - Numero: emulators/numero.md - - NXEngine: emulators/nxengine.md - - O2EM: emulators/o2em.md - - Oberon: emulators/oberon.md - - Omicron: emulators/omicron.md - - ONScripter: emulators/onscripter.md - - ONScripter Yuri: emulators/onsyuri.md - - openbor: emulators/openbor.md - - OpenGOAL: emulators/opengoal.md - - OpenJazz: emulators/openjazz.md - - openjk: emulators/openjk.md - - OpenJKDF2: emulators/openjkdf2.md - - OpenLara: emulators/openlara.md - - OpenMoHAA: emulators/openmohaa.md - - openMSX: emulators/openmsx.md - - OpenTTD: emulators/openttd.md - - OpenTyrian: emulators/opentyrian.md - - Opera (4DO): emulators/opera.md - - Oricutron: emulators/oricutron.md - - Osmose (RetroPie): emulators/osmose.md + - M: + - M2000: emulators/m2000.md + - M64Plus FZ: emulators/m64plus-fz.md + - MagicEngine: emulators/magicengine.md + - MAME: emulators/mame.md + - MAME 0.258: emulators/mame0258.md + - MAME 0.278: emulators/mame0278.md + - MAME 2000: emulators/mame2000.md + - MAME 2003: emulators/mame2003.md + - MAME 2003 Midway: emulators/mame2003_midway.md + - MAME 2003-Plus: emulators/mame2003_plus.md + - MAME 2009: emulators/mame2009.md + - MAME 2010: emulators/mame2010.md + - MAME 2014: emulators/mame2014.md + - MAME 2015: emulators/mame2015.md + - MAME 2016: emulators/mame2016.md + - MAME Arcade: emulators/mamearcade.md + - MAME MESS: emulators/mamemess.md + - MAME4ALL-Pi: emulators/mame4all.md + - MAME4droid (0.139u1): emulators/mame4droid.md + - MAME4droid 2024: emulators/mame4droid-2024.md + - Mandarine: emulators/mandarine.md + - Mariani: emulators/mariani.md + - MasterGear: emulators/mastergear.md + - MD.emu: emulators/md-emu.md + - Mednafen: emulators/mednafen.md + - Mednafen STV: emulators/mednafen_stv.md + - mednafen_gba: emulators/mednafen_gba.md + - mednafen_pce: emulators/mednafen_pce.md + - mednafen_pce_fast: emulators/mednafen_pce_fast.md + - mednafen_snes: emulators/mednafen_snes.md + - mednafen_supafaust: emulators/mednafen_supafaust.md + - mednafen_supergrafx: emulators/mednafen_supergrafx.md + - melonDS: emulators/melonds.md + - melonDS DS: emulators/melonds_ds.md + - melonDS Nightly: emulators/melonds-nightly.md + - Mesen: emulators/mesen.md + - mesen-s: emulators/mesen-s.md + - MesenCE: emulators/mesence.md + - MESS 2015: emulators/mess2015.md + - Meteor GBA: emulators/meteor.md + - MFME: emulators/mfme.md + - mGBA: emulators/mgba.md + - mGBA Splitscreen: emulators/mgba_splitscreen.md + - Mini vMac: emulators/minivmac.md + - Mini vMac 37: emulators/minivmac37.md + - mkxp-z: emulators/mkxp_z.md + - Model 2 Emulator: emulators/model2.md + - MojoZork: emulators/mojozork.md + - MSX.emu: emulators/msx-emu.md + - Mu: emulators/mu.md + - Mupen64Plus: emulators/mupen64plus.md + - Mupen64Plus (libretro, 2019): emulators/mupen64plus-libretro.md + - Mupen64Plus AE: emulators/mupen64plus-ae.md + - Mupen64Plus-Next: emulators/mupen64plus_next.md + - My Boy!: emulators/my-boy.md + - My OldBoy!: emulators/my-oldboy.md + - N: + - NanoBoyAdvance: emulators/nanoboyadvance.md + - Native32Emu: emulators/native32emu.md + - nekop2: emulators/nekop2.md + - NEO.emu: emulators/neo-emu.md + - NeoCD: emulators/neocd.md + - nes: emulators/nes.md + - NES.emu: emulators/nes-emu.md + - Nesoid: emulators/nesoid.md + - Nestopia UE: emulators/nestopia.md + - NetherSX2: emulators/nethersx2.md + - NetherSX2-Turnip: emulators/nethersx2-turnip.md + - NetherSX2-Turnip Classic: emulators/nethersx2-turnip-classic.md + - NGP.emu: emulators/ngp-emu.md + - NicaiEmu: emulators/nicaiemu.md + - no$gba: emulators/nosgba.md + - NooDS: emulators/noods.md + - NP2kai: emulators/np2kai.md + - np2pi: emulators/np2pi.md + - nSide (SFC Balanced): emulators/higan_sfc_balanced.md + - Nuance: emulators/nuance.md + - Numero: emulators/numero.md + - NXEngine: emulators/nxengine.md + - O: + - O2EM: emulators/o2em.md + - Oberon: emulators/oberon.md + - Omicron: emulators/omicron.md + - ONScripter: emulators/onscripter.md + - ONScripter Yuri: emulators/onsyuri.md + - openbor: emulators/openbor.md + - OpenGOAL: emulators/opengoal.md + - OpenJazz: emulators/openjazz.md + - openjk: emulators/openjk.md + - OpenJKDF2: emulators/openjkdf2.md + - OpenLara: emulators/openlara.md + - OpenMoHAA: emulators/openmohaa.md + - openMSX: emulators/openmsx.md + - OpenTTD: emulators/openttd.md + - OpenTyrian: emulators/opentyrian.md + - Opera (4DO): emulators/opera.md + - Oricutron: emulators/oricutron.md + - Osmose (RetroPie): emulators/osmose.md - P to R: - - P/EMU/SDL: emulators/piemu.md - - Panda3DS: emulators/panda3ds.md - - Parallel Launcher: emulators/parallel-launcher.md - - ParaLLEl N64: emulators/parallel_n64.md - - pce-emu: emulators/pce-emu.md - - PCEE2: emulators/pcee2.md - - PCem: emulators/pcem.md - - PCSX-ReARMed: emulators/pcsx_rearmed.md - - PCSX-ReARMed (standalone): emulators/pcsx_rearmed_standalone.md - - PCSX1: emulators/pcsx1.md - - PCSX2: emulators/pcsx2.md - - PCSX2 Legacy: emulators/pcsx2-legacy.md - - PCSX2x6: emulators/pcsx2x6.md - - PD777: emulators/pd777.md - - Perfect Dark (PC port): emulators/pdark.md - - Phoenix: emulators/phoenix.md - - Picocomputer 6502: emulators/rp6502.md - - PicoDrive: emulators/picodrive.md - - piece-emu: emulators/piece.md - - piFBA (fba2x): emulators/fba2x.md - - PiSNES: emulators/pisnes.md - - Pizza Boy GBA: emulators/pizza-boy-gba.md - - Pizza Boy GBC: emulators/pizza-boy-gbc.md - - Pizza Boy SC: emulators/pizza-boy-sc.md - - Plastic: emulators/plastic.md - - play: emulators/play.md - - PlaydiaEmu: emulators/playdiaemu.md - - PokeMini: emulators/pokemini.md - - pokketstation: emulators/pokketstation.md - - POM2: emulators/pom2.md - - Potator: emulators/potator.md - - PPSSPP: emulators/ppsspp.md - - PPSSPP 1.5.4: emulators/ppsspp-1.5.4.md - - PrBoom: emulators/prboom.md - - PrBoom+: emulators/prboom-plus.md - - PrimeHack: emulators/primehack.md - - project64: emulators/project64.md - - ProSystem: emulators/prosystem.md - - pSxMAME: emulators/psxmame.md - - PUAE (P-UAE): emulators/puae.md - - PUAE 2021: emulators/puae2021.md - - puNES: emulators/punes.md - - px68k: emulators/px68k.md - - px68k (SDL): emulators/px68k-sdl.md - - QEMU: emulators/qemu.md - - Quake 3 (Raspberry Pi): emulators/quake3.md - - QUASI88: emulators/quasi88.md - - QUASI88kai: emulators/quasi88kai.md - - QuickNES: emulators/quicknes.md - - RACE (Neo Geo Pocket): emulators/race.md - - radio: emulators/radio.md - - RAINE: emulators/raine.md - - Raze: emulators/raze.md - - RB5000: emulators/rb5000.md - - Real3DOPlayer: emulators/real3doplayer.md - - RealRTCW: emulators/realrtcw.md - - Redream: emulators/redream.md - - Reicast: emulators/reicast.md - - REminiscence: emulators/reminiscence.md - - Retro8: emulators/retro8.md - - RetroDream: emulators/retrodream.md - - RigelEngine: emulators/rigelengine.md - - Rosalie's Mupen GUI: emulators/rosalies-mupen-gui.md - - RPCS3: emulators/rpcs3.md - - rpix86: emulators/rpix86.md - - ruffle: emulators/ruffle.md - - Rust-DOS: emulators/rust_dos.md - - Rustation: emulators/rustation.md - - RustyNES: emulators/rustynes.md - - RVVM: emulators/rvvm.md - - Ryujinx: emulators/ryujinx.md + - P: + - P/EMU/SDL: emulators/piemu.md + - Panda3DS: emulators/panda3ds.md + - Parallel Launcher: emulators/parallel-launcher.md + - ParaLLEl N64: emulators/parallel_n64.md + - pce-emu: emulators/pce-emu.md + - PCEE2: emulators/pcee2.md + - PCem: emulators/pcem.md + - PCSX-ReARMed: emulators/pcsx_rearmed.md + - PCSX-ReARMed (standalone): emulators/pcsx_rearmed_standalone.md + - PCSX1: emulators/pcsx1.md + - PCSX2: emulators/pcsx2.md + - PCSX2 Legacy: emulators/pcsx2-legacy.md + - PCSX2x6: emulators/pcsx2x6.md + - PD777: emulators/pd777.md + - Perfect Dark (PC port): emulators/pdark.md + - Phoenix: emulators/phoenix.md + - Picocomputer 6502: emulators/rp6502.md + - PicoDrive: emulators/picodrive.md + - piece-emu: emulators/piece.md + - piFBA (fba2x): emulators/fba2x.md + - PiSNES: emulators/pisnes.md + - Pizza Boy GBA: emulators/pizza-boy-gba.md + - Pizza Boy GBC: emulators/pizza-boy-gbc.md + - Pizza Boy SC: emulators/pizza-boy-sc.md + - Plastic: emulators/plastic.md + - play: emulators/play.md + - PlaydiaEmu: emulators/playdiaemu.md + - PokeMini: emulators/pokemini.md + - pokketstation: emulators/pokketstation.md + - POM2: emulators/pom2.md + - Potator: emulators/potator.md + - PPSSPP: emulators/ppsspp.md + - PPSSPP 1.5.4: emulators/ppsspp-1.5.4.md + - PrBoom: emulators/prboom.md + - PrBoom+: emulators/prboom-plus.md + - PrimeHack: emulators/primehack.md + - project64: emulators/project64.md + - ProSystem: emulators/prosystem.md + - pSxMAME: emulators/psxmame.md + - PUAE (P-UAE): emulators/puae.md + - PUAE 2021: emulators/puae2021.md + - puNES: emulators/punes.md + - px68k: emulators/px68k.md + - px68k (SDL): emulators/px68k-sdl.md + - Q: + - QEMU: emulators/qemu.md + - Quake 3 (Raspberry Pi): emulators/quake3.md + - QUASI88: emulators/quasi88.md + - QUASI88kai: emulators/quasi88kai.md + - QuickNES: emulators/quicknes.md + - R: + - RACE (Neo Geo Pocket): emulators/race.md + - radio: emulators/radio.md + - RAINE: emulators/raine.md + - Raze: emulators/raze.md + - RB5000: emulators/rb5000.md + - Real3DOPlayer: emulators/real3doplayer.md + - RealRTCW: emulators/realrtcw.md + - Redream: emulators/redream.md + - Reicast: emulators/reicast.md + - REminiscence: emulators/reminiscence.md + - Retro8: emulators/retro8.md + - RetroDream: emulators/retrodream.md + - RigelEngine: emulators/rigelengine.md + - Rosalie's Mupen GUI: emulators/rosalies-mupen-gui.md + - RPCS3: emulators/rpcs3.md + - rpix86: emulators/rpix86.md + - ruffle: emulators/ruffle.md + - Rust-DOS: emulators/rust_dos.md + - Rustation: emulators/rustation.md + - RustyNES: emulators/rustynes.md + - RVVM: emulators/rvvm.md + - Ryujinx: emulators/ryujinx.md - S to U: - - SAME CDi: emulators/same_cdi.md - - SameBoy: emulators/sameboy.md - - sameduck: emulators/sameduck.md - - Saturn.emu: emulators/saturn-emu.md - - ScummVM: emulators/scummvm.md - - ScummVM (SDL1): emulators/scummvm-sdl1.md - - SDL2TRS: emulators/sdl2trs.md - - SDLPAL: emulators/sdlpal.md - - sdlpop: emulators/sdlpop.md - - shadps4: emulators/shadps4.md - - SheepShaver: emulators/sheepshaver.md - - Ship of Harkinian: emulators/soh.md - - SimCoupe: emulators/simcp.md - - SimCoupe (standalone): emulators/samcoupe.md - - simple64: emulators/simple64.md - - Singe 2: emulators/singe2.md - - sixtyforce: emulators/sixtyforce.md - - skyemu: emulators/skyemu.md - - Skyline: emulators/skyline.md - - SMS Plus GX: emulators/smsplus.md - - snes9x: emulators/snes9x.md - - Snes9x 2002: emulators/snes9x2002.md - - Snes9x 2005: emulators/snes9x2005.md - - Snes9x 2005 Plus: emulators/snes9x2005_plus.md - - Snes9x 2010: emulators/snes9x2010.md - - Snes9x EX+: emulators/snes9x-explus.md - - solarus: emulators/solarus.md - - Solarus 2.0.0: emulators/solarus-2.0.0.md - - Sonic 1 & 2 (2013) Decompilation: emulators/sonic2013.md - - Sonic 3 A.I.R.: emulators/sonic3-air.md - - Sonic CD Decompilation: emulators/soniccd.md - - Sonic Mania Decompilation: emulators/sonic-mania.md - - Sonic Robo Blast 2: emulators/srb2.md - - Speccy: emulators/speccy.md - - SplitWolf: emulators/splitwolf.md - - SPMP8000Emu: emulators/spmp8000emu.md - - SquirrelJME: emulators/squirreljme.md - - SSF: emulators/ssf.md - - Starboard: emulators/starboard.md - - Starship: emulators/starship.md - - Stella: emulators/stella.md - - Stella 2014: emulators/stella2014.md - - Stella 2023: emulators/stella2023.md - - Sudachi: emulators/sudachi.md - - Sugarbox2: emulators/sugarbox2.md - - Super Mario War: emulators/supermariowar.md - - SUPER ZSNES: emulators/superzsnes.md - - SUPER3: emulators/super3.md - - Supermodel: emulators/supermodel.md - - Supermodel (Sinden fork): emulators/supermodel3.md - - Supermodel Dojo: emulators/supermodel-dojo.md - - Supermodel Legacy: emulators/supermodel-legacy.md - - SuperSnes9x: emulators/supersnes9x.md - - SuperTux: emulators/supertux.md - - SuperTuxKart: emulators/supertuxkart.md - - Suyu: emulators/suyu.md - - Swan.emu: emulators/swan-emu.md - - swanstation: emulators/swanstation.md - - SWF Player: emulators/swf-player.md - - Syobon Action: emulators/syobonaction.md - - TamaLIBretro: emulators/tamalibretro.md - - Taradino: emulators/taradino.md - - TeknoParrot: emulators/teknoparrot.md - - TempGBA: emulators/tempgba.md - - TGB Dual: emulators/tgbdual.md - - The Force Engine: emulators/theforceengine.md - - Theodore: emulators/theodore.md - - TheXTech: emulators/thextech.md - - ti99sim: emulators/ti99sim.md - - ti99sim-sdl1: emulators/ti99sim-sdl1.md - - Tia: emulators/tia.md - - TIC-80: emulators/tic80.md - - touchHLE: emulators/touchhle.md - - TR1X: emulators/tr1x.md - - TR2X: emulators/tr2x.md - - trident: emulators/trident.md - - Triforce: emulators/triforce.md - - tsugaru: emulators/tsugaru.md - - TyrQuake: emulators/tyrquake.md - - UAE4ALL (libretro, Chips-fr): emulators/uae4all-rpi.md - - UAE4All2: emulators/uae4all.md - - uae4arm: emulators/uae4arm.md - - UME 2015: emulators/ume2015.md - - uqm: emulators/uqm.md - - UZDoom: emulators/uzdoom.md - - uzem: emulators/uzem.md + - S: + - SAME CDi: emulators/same_cdi.md + - SameBoy: emulators/sameboy.md + - sameduck: emulators/sameduck.md + - Saturn.emu: emulators/saturn-emu.md + - ScummVM: emulators/scummvm.md + - ScummVM (SDL1): emulators/scummvm-sdl1.md + - SDL2TRS: emulators/sdl2trs.md + - SDLPAL: emulators/sdlpal.md + - sdlpop: emulators/sdlpop.md + - shadps4: emulators/shadps4.md + - SheepShaver: emulators/sheepshaver.md + - Ship of Harkinian: emulators/soh.md + - SimCoupe: emulators/simcp.md + - SimCoupe (standalone): emulators/samcoupe.md + - simple64: emulators/simple64.md + - Singe 2: emulators/singe2.md + - sixtyforce: emulators/sixtyforce.md + - skyemu: emulators/skyemu.md + - Skyline: emulators/skyline.md + - SMS Plus GX: emulators/smsplus.md + - snes9x: emulators/snes9x.md + - Snes9x 2002: emulators/snes9x2002.md + - Snes9x 2005: emulators/snes9x2005.md + - Snes9x 2005 Plus: emulators/snes9x2005_plus.md + - Snes9x 2010: emulators/snes9x2010.md + - Snes9x EX+: emulators/snes9x-explus.md + - solarus: emulators/solarus.md + - Solarus 2.0.0: emulators/solarus-2.0.0.md + - Sonic 1 & 2 (2013) Decompilation: emulators/sonic2013.md + - Sonic 3 A.I.R.: emulators/sonic3-air.md + - Sonic CD Decompilation: emulators/soniccd.md + - Sonic Mania Decompilation: emulators/sonic-mania.md + - Sonic Robo Blast 2: emulators/srb2.md + - Speccy: emulators/speccy.md + - SplitWolf: emulators/splitwolf.md + - SPMP8000Emu: emulators/spmp8000emu.md + - SquirrelJME: emulators/squirreljme.md + - SSF: emulators/ssf.md + - Starboard: emulators/starboard.md + - Starship: emulators/starship.md + - Stella: emulators/stella.md + - Stella 2014: emulators/stella2014.md + - Stella 2023: emulators/stella2023.md + - Sudachi: emulators/sudachi.md + - Sugarbox2: emulators/sugarbox2.md + - Super Mario War: emulators/supermariowar.md + - SUPER ZSNES: emulators/superzsnes.md + - SUPER3: emulators/super3.md + - Supermodel: emulators/supermodel.md + - Supermodel (Sinden fork): emulators/supermodel3.md + - Supermodel Dojo: emulators/supermodel-dojo.md + - Supermodel Legacy: emulators/supermodel-legacy.md + - SuperSnes9x: emulators/supersnes9x.md + - SuperTux: emulators/supertux.md + - SuperTuxKart: emulators/supertuxkart.md + - Suyu: emulators/suyu.md + - Swan.emu: emulators/swan-emu.md + - swanstation: emulators/swanstation.md + - SWF Player: emulators/swf-player.md + - Syobon Action: emulators/syobonaction.md + - T: + - TamaLIBretro: emulators/tamalibretro.md + - Taradino: emulators/taradino.md + - TeknoParrot: emulators/teknoparrot.md + - TempGBA: emulators/tempgba.md + - TGB Dual: emulators/tgbdual.md + - The Force Engine: emulators/theforceengine.md + - Theodore: emulators/theodore.md + - TheXTech: emulators/thextech.md + - ti99sim: emulators/ti99sim.md + - ti99sim-sdl1: emulators/ti99sim-sdl1.md + - Tia: emulators/tia.md + - TIC-80: emulators/tic80.md + - touchHLE: emulators/touchhle.md + - TR1X: emulators/tr1x.md + - TR2X: emulators/tr2x.md + - trident: emulators/trident.md + - Triforce: emulators/triforce.md + - tsugaru: emulators/tsugaru.md + - TyrQuake: emulators/tyrquake.md + - U: + - UAE4ALL (libretro, Chips-fr): emulators/uae4all-rpi.md + - UAE4All2: emulators/uae4all.md + - uae4arm: emulators/uae4arm.md + - UME 2015: emulators/ume2015.md + - uqm: emulators/uqm.md + - UZDoom: emulators/uzdoom.md + - uzem: emulators/uzem.md - V to Z: - - VaporSpec: emulators/vaporspec.md - - VBA-M: emulators/vbam.md - - VBA-Next: emulators/vba_next.md - - vecx: emulators/vecx.md - - veesem: emulators/veesem.md - - VeMUlator: emulators/vemulator.md - - VICE: emulators/vice.md - - VICE x128: emulators/vice_x128.md - - VICE x64: emulators/vice_x64.md - - VICE x64dtv: emulators/vice_x64dtv.md - - VICE x64sc: emulators/vice_x64sc.md - - VICE xcbm2: emulators/vice_xcbm2.md - - VICE xcbm5x0: emulators/vice_xcbm5x0.md - - VICE xpet: emulators/vice_xpet.md - - VICE xplus4: emulators/vice_xplus4.md - - VICE xscpu64: emulators/vice_xscpu64.md - - VICE xvic: emulators/vice_xvic.md - - Vircon32: emulators/vircon32.md - - Virtual Jaguar: emulators/virtualjaguar.md - - Virtual Virtual Boy: emulators/virtual-virtual-boy.md - - VirtualXT: emulators/virtualxt.md - - visual-pinball: emulators/visual-pinball.md - - Vita3K: emulators/vita3k.md - - vitaQuakeII: emulators/vitaquake2.md - - vitaQuakeIII: emulators/vitaquake3.md - - vitavoyager: emulators/vitavoyager.md - - vkQuake: emulators/vkquake.md - - vkQuake2: emulators/vkquake2.md - - vkQuake3: emulators/vkquake3.md - - VVVVVV: emulators/vvvvvv.md - - WinUAE: emulators/winuae.md - - Wolf4SDL: emulators/wolf4sdl.md - - WQXEmu: emulators/wqxemu.md - - X Millennium: emulators/x1.md - - X1 BOX: emulators/x1-box.md - - x16emu: emulators/x16emu.md - - x64sdl: emulators/x64sdl.md - - Xash3D FWGS: emulators/xash3d_fwgs.md - - xemu: emulators/xemu.md - - xemu Chihiro: emulators/xemu-chihiro.md - - XenDroid: emulators/xendroid.md - - Xenia Canary: emulators/xenia.md - - Xenia Edge: emulators/xenia-edge.md - - XeniOS: emulators/xenios.md - - xm6pro68k: emulators/xm6pro68k.md - - xm6typeg: emulators/xm6typeg.md - - XM7: emulators/xm7.md - - XRick: emulators/xrick.md - - XRoar: emulators/xroar.md - - yabasanshiro: emulators/yabasanshiro.md - - yabause: emulators/yabause.md - - Yamagi Quake II: emulators/yquake2.md - - yaps2: emulators/yaps2.md - - ymir: emulators/ymir.md - - Yuzu: emulators/yuzu.md - - Zelda Classic 2.55 Prototype: emulators/zc255.md - - Zelda Classic v2.10: emulators/zc210.md - - ZEsarUX: emulators/zesarux.md - - ZiNc: emulators/zinc.md - - ZQuest Classic: emulators/zquestclassic.md + - V: + - VaporSpec: emulators/vaporspec.md + - VBA-M: emulators/vbam.md + - VBA-Next: emulators/vba_next.md + - vecx: emulators/vecx.md + - veesem: emulators/veesem.md + - VeMUlator: emulators/vemulator.md + - VICE: emulators/vice.md + - VICE x128: emulators/vice_x128.md + - VICE x64: emulators/vice_x64.md + - VICE x64dtv: emulators/vice_x64dtv.md + - VICE x64sc: emulators/vice_x64sc.md + - VICE xcbm2: emulators/vice_xcbm2.md + - VICE xcbm5x0: emulators/vice_xcbm5x0.md + - VICE xpet: emulators/vice_xpet.md + - VICE xplus4: emulators/vice_xplus4.md + - VICE xscpu64: emulators/vice_xscpu64.md + - VICE xvic: emulators/vice_xvic.md + - Vircon32: emulators/vircon32.md + - Virtual Jaguar: emulators/virtualjaguar.md + - Virtual Virtual Boy: emulators/virtual-virtual-boy.md + - VirtualXT: emulators/virtualxt.md + - visual-pinball: emulators/visual-pinball.md + - Vita3K: emulators/vita3k.md + - vitaQuakeII: emulators/vitaquake2.md + - vitaQuakeIII: emulators/vitaquake3.md + - vitavoyager: emulators/vitavoyager.md + - vkQuake: emulators/vkquake.md + - vkQuake2: emulators/vkquake2.md + - vkQuake3: emulators/vkquake3.md + - VVVVVV: emulators/vvvvvv.md + - W: + - WinUAE: emulators/winuae.md + - Wolf4SDL: emulators/wolf4sdl.md + - WQXEmu: emulators/wqxemu.md + - X: + - X Millennium: emulators/x1.md + - X1 BOX: emulators/x1-box.md + - x16emu: emulators/x16emu.md + - x64sdl: emulators/x64sdl.md + - Xash3D FWGS: emulators/xash3d_fwgs.md + - xemu: emulators/xemu.md + - xemu Chihiro: emulators/xemu-chihiro.md + - XenDroid: emulators/xendroid.md + - Xenia Canary: emulators/xenia.md + - Xenia Edge: emulators/xenia-edge.md + - XeniOS: emulators/xenios.md + - xm6pro68k: emulators/xm6pro68k.md + - xm6typeg: emulators/xm6typeg.md + - XM7: emulators/xm7.md + - XRick: emulators/xrick.md + - XRoar: emulators/xroar.md + - Y: + - yabasanshiro: emulators/yabasanshiro.md + - yabause: emulators/yabause.md + - Yamagi Quake II: emulators/yquake2.md + - yaps2: emulators/yaps2.md + - ymir: emulators/ymir.md + - Yuzu: emulators/yuzu.md + - Z: + - Zelda Classic 2.55 Prototype: emulators/zc255.md + - Zelda Classic v2.10: emulators/zc210.md + - ZEsarUX: emulators/zesarux.md + - ZiNc: emulators/zinc.md + - ZQuest Classic: emulators/zquestclassic.md - All installer options: wiki/installer.md - Reference: - wiki/index.md diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b94d3779..d12f9e1d 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1430,6 +1430,8 @@ def _browser_title(relative: Path, title: str) -> str: } if key in index_titles: return index_titles[key] + if key in PART_PAGES: + return title if key.startswith("emulators/"): return f"{title} emulator firmware" if key.startswith("systems/"): @@ -1474,6 +1476,9 @@ def _page_description(markdown: str, relative: Path, title: str) -> str: } if key in specific: return specific[key] + if key in PART_PAGES: + return (f"{title}: every file with its hashes, size, source references " + "and who reads it.") if key.startswith("platforms/") and relative.stem != "index": return (f"{title}: where the BIOS files go, the command that installs them, the pack, " "and every file by system with its hashes.") @@ -1510,9 +1515,13 @@ def _page_description(markdown: str, relative: Path, title: str) -> str: # Structured data a page generator knows beyond its text, by page path # relative to docs/: read by decorate_markdown_pages. PAGE_DATA: dict[str, dict] = {} +# Pages that hold part of a page too long for one (a console of a split +# manufacturer, a run of an emulator's cards), by path relative to docs/: +# their H1 already says what they are. +PART_PAGES: set[str] = set() -SYSTEM_PARTS_IN_FULL = 1000 +SYSTEM_PARTS_IN_FULL = 250 def collection_dat(db: dict, generated_at: str) -> str: @@ -1601,8 +1610,9 @@ def system_structured_data(slug: str, consoles: dict[str, list]) -> dict: part["citation"] = citations parts.append(part) if len(parts) > SYSTEM_PARTS_IN_FULL: - # game-engines would carry 2.6 MB of JSON-LD: past the cap a page - # lists its catalogue-verified files and points at the full table. + # A page of cards carried up to 2.6 MB of JSON-LD (game-engines), a + # quarter of its weight: past the cap a page lists its + # catalogue-verified files and points at the full table. return { "hasPart": [part for part in parts if part.get("citation")], "isBasedOn": urllib.parse.urljoin(SITE_URL, "downloads/files.csv"), @@ -1824,6 +1834,11 @@ def emulator_structured_data(profile: dict) -> dict: if members: part["hasPart"] = members parts.append(part) + if len(parts) > SYSTEM_PARTS_IN_FULL: + # MAME's 1,564 entries and their members were 614 KB of JSON-LD: past + # the cap the members are left to the profile's JSON. + for part in parts: + part.pop("hasPart", None) if parts: data["hasPart"] = parts return data @@ -2044,14 +2059,23 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st return "\n".join(lines) + "\n" +# A platform system whose file list passes this much Markdown gets a page +# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page. +PLATFORM_SECTION_BUDGET = 200_000 + + def _render_platform_systems( - by_system: dict, config_files: dict, emulator_files: dict + by_system: dict, config_files: dict, emulator_files: dict, + platform: tuple[str, str] | None = None, moved: dict[str, str] | None = None, ) -> list[str]: """One collapsible block per system, listing every file it needs. Split out of generate_platform_page: the block is most of that function's branching, since each file renders a different set of hashes, sizes and provenance depending on what the platform declares. + With ``platform`` (its key and name) and ``moved``, a list longer than + PLATFORM_SECTION_BUDGET goes to a page of its own, written into + ``moved`` by stem, and the system's heading stays with a link to it. """ lines: list[str] = [] # Per-system detail sections (collapsible for large platforms) @@ -2154,6 +2178,26 @@ def _render_platform_systems( if f.get("category_evidence"): lines.append(f"{pad} {_category_evidence_md(f['category_evidence'])}") + block = lines[block_start:] + if moved is not None and platform and sum(len(line) + 1 for line in block) > PLATFORM_SECTION_BUDGET: + key, display = platform + stem = f"{key}-{anchor}" + body = block[2:] if use_collapsible else block + moved[stem] = "\n".join([ + f"# {display}: {sys_id}", "", + f"Part of the [{display} page]({key}.md#{anchor}), whose other systems " + f"are listed there. Each file below is as {display} declares it, with " + "its status in the pack.", + "", "## Files", "", + *search_excluded( + [line[len(pad):] if line.startswith(pad) else line for line in body]), + "", f"*Generated on {_timestamp()}*", + ]) + "\n" + lines[block_start:] = [ + f"{_count(total, 'file')}, {ok_count} verified: listed on " + f"[a page of their own]({stem}.md).", "", + ] + continue # The section's heading stays searchable, its file list with every # hash is the lookup's: retrodeck's arcade list was 495 KB of index. lines[block_start:] = search_excluded(lines[block_start:]) @@ -2386,9 +2430,13 @@ def generate_platform_page( cov: dict, registry: dict | None = None, emulator_files: dict | None = None, + moved: dict[str, str] | None = None, ) -> str: """A guide first (folder, command, pack, check), then every file the - platform's list and its emulators need, under a reference heading.""" + platform's list and its emulators need, under a reference heading. + + ``moved`` collects the pages of systems whose list is too long for it. + """ registry = registry or {} config = cov["config"] display = cov["platform"] @@ -2485,7 +2533,8 @@ def generate_platform_page( "", "", ] - lines.extend([*band, "| | |", "|---|---|"]) + lines.extend([*band, '
', "", + "| Detail | Value |", "|---|---|"]) if hash_type: lines.append(f"| Hash type | {hash_type} |") if version: @@ -2519,6 +2568,7 @@ def generate_platform_page( f"| Contributed by | [@{username}](https://github.com/{username}):" f" {contribution}{pr_link} |" ) + lines.extend(["", "
"]) if name not in GUIDES: detect = (registry.get(name, {}).get("install") or {}).get("detect") or [] on_card = any(rule.get("method") == "card_marker" for rule in detect) @@ -2576,7 +2626,8 @@ def generate_platform_page( lines.append("") lines.extend( - _render_platform_systems(by_system, config_files, emulator_files) + _render_platform_systems(by_system, config_files, emulator_files, + (name, display), moved) ) lines.append(f"*Generated on {_timestamp()}*") @@ -2701,8 +2752,8 @@ def file_location_items( parts = entry.get("path", "").split("/") if len(parts) < 3 or parts[0] != "bios": continue - slug = parts[1].lower().replace(" ", "-") - items.append({"sha1": sha1, "url": f"systems/{slug}/#sha1-{sha1[:12]}"}) + page = card_page(entry.get("path", ""), sha1) + items.append({"sha1": sha1, "url": f"systems/{page}/#sha1-{sha1[:12]}"}) # A data directory's files are listed on the page of an emulator reading # the tree: 2,534 hashes the packs ship were found nowhere on the site. owners = _data_member_owners(profiles or {}) @@ -2772,7 +2823,7 @@ def file_lookup_records( continue entries = profile.get("files") or [] for entry, anchor in zip(entries, emulator_file_anchors(entries)): - url = f"emulators/{key}/#{anchor}" + url = f"emulators/{emulator_card_page(key, anchor)}/#{anchor}" members = [(str(entry.get("name", "")), entry)] + [ (str(member["name"]), member) for member in entry.get("contents") or [] if isinstance(member, dict) and member.get("name") @@ -3207,8 +3258,9 @@ def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]: index = entries.get(emulator) anchor = (_emulator_card_anchor(emulator, files_of[emulator], index) if index is not None and emulator in files_of else "") + page = emulator_card_page(emulator, anchor) if anchor else emulator fragment = f"#{anchor}" if anchor else "" - links.append(f"[{emulator}](../emulators/{emulator}.md{fragment})") + links.append(f"[{emulator}](../emulators/{page}.md{fragment})") return links @@ -3379,14 +3431,16 @@ def _card_title(text: str, anchor: str, searchable: bool = True) -> str: """A file card's heading, so the site search lands on the card. The search splits a page at its headings: a card titled in bold sent a - pasted md5 to the top of a page of a hundred cards. Level 4 stays out - of the table of contents (toc_depth 3), and a card without an anchor - keeps the id the toc gives it. + pasted md5 to the top of a page of a hundred cards. Level 3, under + the console or the Files heading: a level 4 after a level 2 skipped + one for screen readers on 551 pages. The table of contents of a page + of cards stops above it (mkdocs_hooks.py), and a card without an + anchor keeps the id the toc gives it. """ attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title" if not searchable: attributes += " data-search-exclude" - return f"#### {text} {{ {attributes} }}" + return f"### {text} {{ {attributes} }}" def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[str, str]]]]: @@ -3418,6 +3472,197 @@ def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[st return choices +# A manufacturer page above this much Markdown becomes a summary with one +# page per console, and a console above it is cut into parts. 500 KB of +# Markdown renders to about 750 KB of HTML: game-engines was one 10.5 MB +# page that took 21 s of main-thread work on a phone. +SYSTEM_PAGE_BUDGET = 500_000 + +# A system card: whether it is a variant, its lines, its sha1. +Card = tuple[bool, list[str], str] + + +def _console_cards( + manufacturer: str, + files: list[dict], + consumers: FileConsumers, + anchored: set[str], +) -> list[Card]: + """Each card of one console, primaries by name then variants, with + whether it is a variant.""" + searchable = manufacturer not in GAME_DATA_TOPS + main_files = [f for f in files if "/.variants/" not in f["path"]] + variant_files = [f for f in files if "/.variants/" in f["path"]] + cards = [(False, _system_file_card(f, consumers, anchored, searchable), str(f.get("sha1", ""))) + for f in sorted(main_files, key=lambda x: x["name"])] + cards += [(True, _system_file_card(v, consumers, anchored, searchable), str(v.get("sha1", ""))) + for v in sorted(variant_files, key=lambda x: (x["name"], x["path"]))] + return cards + + +def _cards_markdown(cards: list[Card]) -> list[str]: + """Primaries, then the variants in a closed block.""" + lines = [line for variant, card, _sha1 in cards if not variant for line in card] + variants = [card for variant, card, _sha1 in cards if variant] + if variants: + # A variant gets the same card as a primary: the revision a + # platform pins is the one a reader comes to find, and showed as + # a name and a truncated MD5, nothing said who pins it. + lines.append(f'??? note "Variants ({len(variants)})"') + lines.append("") + for card in variants: + lines.extend(f" {line}" if line else "" for line in card) + lines.append("") + return lines + + +def _parts(cards: list[Card], budget: int) -> list[list[Card]]: + """Cards in runs of at most ``budget`` bytes of Markdown, in order.""" + parts: list[list[Card]] = [[]] + size = 0 + for card in cards: + weight = sum(len(line) + 1 for line in card[1]) + if parts[-1] and size + weight > budget: + parts.append([]) + size = 0 + parts[-1].append(card) + size += weight + return parts + + +_NEXT_FROM_SYSTEMS = [ + "[Get your files](../which-pack.md), for the setup you play on", + "[When a game does not start](../wiki/troubleshooting.md)", + "[Every emulator](../emulators/index.md), and the files each one reads", +] + + +def system_pages( + manufacturer: str, + consoles: dict[str, list], + consumers: FileConsumers, + installs: dict[str, list[tuple[str, str]]] | None = None, + budget: int = SYSTEM_PAGE_BUDGET, +) -> dict[str, tuple[str, dict[str, list]]]: + """A manufacturer's pages by stem under systems/: one page, or a summary + and a page per console when the whole would pass ``budget``. Each page + comes with the files whose cards it holds, by console. + + Every console keeps its heading on the summary, so a link to + systems//# still lands, and site.js sends a card + anchor the page no longer holds to the card's new place. The pages + each card and console went to are recorded for every other link + (siterender.record_console_pages). + """ + slug = _slug(manufacturer) + json_link = (f"[JSON](../api/v1/systems/{urllib.parse.quote(slug)}.json)") + whole_anchors: set[str] = set() + sections = { + console: _console_cards(manufacturer, consoles[console], consumers, whole_anchors) + for console in sorted(consoles) + } + lines = [ + f"# {manufacturer}", "", + f"The files below by console, with every hash, are also published as {json_link}.", + "", + ] + for console, cards in sections.items(): + lines.append(f"## {system_icon_markdown(manufacturer, console)}{console}") + lines.append("") + lines.extend(_console_install_block(console, (installs or {}).get(console, []))) + lines.extend(_cards_markdown(cards)) + lines.append("") + lines.extend(_next_block(_NEXT_FROM_SYSTEMS)) + lines.append(f'
Generated on {_timestamp()}.
') + whole = "\n".join(lines) + "\n" + if len(whole) <= budget: + return {slug: (whole, consoles)} + + # The heaviest sections move to pages of their own until what stays on + # the manufacturer's page is half the budget: a section of one file + # keeps its place, and a reader does not open 91 pages to browse Other. + weights = { + console: sum(len(line) + 1 for _variant, card, _sha1 in cards for line in card) + for console, cards in sections.items() + } + moved: set[str] = set() + for console in sorted(weights, key=lambda c: (-weights[c], c)): + if sum(w for c, w in weights.items() if c not in moved) <= budget // 2: + break + moved.add(console) + + pages: dict[str, tuple[str, dict[str, list]]] = {} + summary = [ + f"# {manufacturer}", "", + f"The files below by console, with every hash, are also published as {json_link}. " + f"The largest sections have a page of their own, linked from their heading.", + "", + ] + summary_anchors: set[str] = set() + for console in sorted(consoles): + summary.append(f"## {system_icon_markdown(manufacturer, console)}{console}") + summary.append("") + if console not in moved: + summary.extend(_console_install_block(console, (installs or {}).get(console, []))) + summary.extend(_cards_markdown( + _console_cards(manufacturer, consoles[console], consumers, summary_anchors))) + summary.append("") + continue + # Cards are rendered again for their own page: the anchors a page + # holds are its own. + cards = _console_cards(manufacturer, consoles[console], consumers, set()) + parts = _parts(cards, budget) + base = f"{slug}-{_slugify_anchor(console) or 'files'}" + stems = [base if index == 0 else f"{base}-{index + 1}" for index in range(len(parts))] + record_console_pages(slug, console, stems, [ + [sha1 for _variant, _card, sha1 in part] for part in parts + ]) + title = f"{manufacturer}: {console}" + count = _count(len(consoles[console]), "file") + if len(stems) == 1: + summary.append(f"{count}, on [their own page]({stems[0]}.md).") + else: + summary.append(f"{count}, on {len(stems)} pages by file name:") + summary.append("") + for index, (stem, part) in enumerate(zip(stems, parts)): + summary.append(f"- [Part {index + 1}]({stem}.md): {_part_range(part)}") + summary.append("") + for index, (stem, part) in enumerate(zip(stems, parts)): + heading = title if len(stems) == 1 else f"{title}, part {index + 1} of {len(stems)}" + lines = [ + f"# {heading}", "", + f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md). " + f"Every hash of these files is also published as {json_link}.", + "", + ] + if len(stems) > 1: + others = ", ".join( + f"[part {i + 1}]({other}.md)" for i, other in enumerate(stems) if i != index) + lines.extend([f"This part holds {_part_range(part)}. The others: {others}.", ""]) + lines.extend(_console_install_block(console, (installs or {}).get(console, []))) + lines.extend(["## Files", ""]) + lines.extend(_cards_markdown(part)) + lines.append("") + lines.extend(_next_block([f"[{manufacturer}]({slug}.md), every section"] + + _NEXT_FROM_SYSTEMS)) + lines.append(f'
Generated on {_timestamp()}.
') + held = {sha1 for _variant, _card, sha1 in part} + pages[stem] = ("\n".join(lines) + "\n", {console: [ + f for f in consoles[console] if str(f.get("sha1", "")) in held]}) + summary.extend(_next_block(_NEXT_FROM_SYSTEMS)) + summary.append(f'
Generated on {_timestamp()}.
') + kept = {console: files for console, files in consoles.items() if console not in moved} + return {slug: ("\n".join(summary) + "\n", kept), **pages} + + +def _part_range(part: list[Card]) -> str: + """The first and last file names of a part, as the cards show them.""" + names = [re.search(r"`([^`]*)`", card[2]).group(1) for _variant, card, _sha1 in part] + if len(names) == 1: + return f"`{names[0]}`" + return f"{_count(len(names), 'file')}, `{names[0]}` to `{names[-1]}`" + + def generate_system_page( manufacturer: str, consoles: dict[str, list], @@ -3427,53 +3672,10 @@ def generate_system_page( """One manufacturer's page, a section per console. ``installs`` gives, per console, the (platform, system id) pairs whose - install command the section offers. + install command the section offers. A page over the budget is the + summary system_pages writes, its consoles on pages of their own. """ - slug = manufacturer.lower().replace(" ", "-") - lines = [ - f"# {manufacturer}", - "", - f"The files below by console, with every hash, are also published as " - f"[JSON](../api/v1/systems/{urllib.parse.quote(slug)}.json).", - "", - ] - anchored: set[str] = set() - searchable = manufacturer not in GAME_DATA_TOPS - - for console_name in sorted(consoles.keys()): - files = consoles[console_name] - icon_md = system_icon_markdown(manufacturer, console_name) - lines.append(f"## {icon_md}{console_name}") - lines.append("") - lines.extend(_console_install_block(console_name, (installs or {}).get(console_name, []))) - main_files = [f for f in files if "/.variants/" not in f["path"]] - variant_files = [f for f in files if "/.variants/" in f["path"]] - - for f in sorted(main_files, key=lambda x: x["name"]): - lines.extend(_system_file_card(f, consumers, anchored, searchable)) - - if variant_files: - # A variant gets the same card as a primary: the revision a - # platform pins is the one a reader comes to find, and showed as - # a name and a truncated MD5, nothing said who pins it. - lines.append(f'??? note "Variants ({len(variant_files)})"') - lines.append("") - for v in sorted(variant_files, key=lambda x: (x["name"], x["path"])): - lines.extend( - f" {line}" if line else "" - for line in _system_file_card(v, consumers, anchored, searchable) - ) - lines.append("") - - lines.append("") - - lines.extend(_next_block([ - "[Get your files](../which-pack.md), for the setup you play on", - "[When a game does not start](../wiki/troubleshooting.md)", - "[Every emulator](../emulators/index.md), and the files each one reads", - ])) - lines.append(f'
Generated on {_timestamp()}.
') - return "\n".join(lines) + "\n" + return system_pages(manufacturer, consoles, consumers, installs)[_slug(manufacturer)][0] # Emulator pages @@ -3914,7 +4116,7 @@ def _render_emulator_file( # Which copy answers this entry, linked to its collection card # (provenance, every hash, download): an entry declaring no hash # said "in repo" without naming the file. - page = parts[1].lower().replace(" ", "-") + page = card_page(held, sha1) details.append( f"Collection copy: [{_code(held)}]" f"(../systems/{urllib.parse.quote(page)}.md#sha1-{sha1[:12]})" @@ -3988,7 +4190,7 @@ def _held_copy_line(copy: tuple[str, str], consumers: FileConsumers) -> str: archive = _code(path) zsha1 = consumers.sha1_at(path) if zsha1 and len(parts) >= 3 and parts[0] == "bios": - page = urllib.parse.quote(parts[1].lower().replace(" ", "-")) + page = urllib.parse.quote(card_page(path, zsha1)) archive = f"[{archive}](../systems/{page}.md#sha1-{zsha1[:12]})" return f"Collection copy: member {_code(member)} of {archive}" if parts[0] == "data" and len(parts) >= 2: @@ -4020,8 +4222,7 @@ def _directory_copies(entry: dict, consumers: FileConsumers) -> list[str]: parts = folder.split("/") where = _code(folder + "/") if len(parts) >= 3 and parts[0] == "bios": - page = urllib.parse.quote(parts[1].lower().replace(" ", "-")) - where = f"[{where}](../systems/{page}.md#{_slugify_anchor(parts[2])})" + where = f"[{where}]({console_href(_slug(parts[1]), parts[2], '../')})" tree = f"{REPO_URL}/tree/main/{urllib.parse.quote(folder)}" return [f"Collection copies: {_count(len(members), 'file')} under {where} " f"([browse]({tree}))"] @@ -4369,6 +4570,41 @@ _DATA_MEMBERS: dict[str, dict[str, dict]] = {} _DATA_REVISIONS: dict[str, dict] = {} +# An emulator page lists at most this many file cards: MAME's 1,564 made a +# 3 MB page. Past it, the cards go to pages of their own and the emulator's +# page keeps a table of every entry, each row holding the card's old +# anchor. A count, not a size: system cards link to these cards and are +# written before them. +EMULATOR_CARDS_PER_PAGE = 400 + + +def emulator_parts(name: str, files: list) -> list[tuple[str, range]]: + """The pages an emulator's cards go to, with the entries each holds, + or none when they fit on the emulator's page. Balanced, in profile order.""" + if len(files) <= EMULATOR_CARDS_PER_PAGE: + return [] + count = -(-len(files) // EMULATOR_CARDS_PER_PAGE) + size = -(-len(files) // count) + return [(f"{name}-files-{index + 1}", range(start, min(start + size, len(files)))) + for index, start in enumerate(range(0, len(files), size))] + + +def plan_emulator_pages(profiles: dict) -> None: + """Record which page holds each emulator card, before any page links one.""" + stems = set() + for name, profile in sorted(profiles.items()): + if profile.get("type") in ("alias", "test"): + continue + files = profile.get("files") or [] + anchors = emulator_file_anchors(files) + for stem, entries in emulator_parts(name, files): + stems.add(stem) + record_emulator_cards(name, stem, [anchors[i] for i in entries]) + clash = stems & set(profiles) + if clash: + raise RuntimeError(f"an emulator's card page takes a profile's address: {sorted(clash)}") + + def generate_emulator_page( name: str, profile: dict, @@ -4379,13 +4615,28 @@ def generate_emulator_page( data_dirs: list[dict] | None = None, ) -> str: """One emulator's page. ``data_dirs`` defaults to the profile's own.""" + return emulator_pages( + name, profile, db, consumers, data_names, data_registry, data_dirs)[name] + + +def emulator_pages( + name: str, + profile: dict, + db: dict, + consumers: FileConsumers | None = None, + data_names: set[str] | None = None, + data_registry: dict | None = None, + data_dirs: list[dict] | None = None, +) -> dict[str, str]: + """An emulator's page by stem under emulators/, and the pages its cards + went to when there are more than EMULATOR_CARDS_PER_PAGE.""" if profile.get("type") == "alias": parent = profile.get("alias_of", profile.get("bios_identical_to", "unknown")) - return ( + return {name: ( f"# {name}\n\n" f"This core uses the same firmware as **{parent}**.\n\n" f"See [{parent}]({parent}.md) for details.\n" - ) + )} emu_name = profile.get("emulator", name) emu_type = profile.get("type", "unknown") @@ -4419,12 +4670,13 @@ def generate_emulator_page( if data_dirs is None: data_dirs = profile.get("data_directories", []) + part_pages: dict[str, str] = {} lines = [ f"# {emu_name}", "", '
', "", - "| | |", + "| Detail | Value |", "|---|---|", f"| Type | {emu_type} |", ] @@ -4522,16 +4774,25 @@ def generate_emulator_page( else: _file_available = _availability_check(db, data_names) + lines.extend(["## Files", ""]) lines.extend(_emulator_file_summary(files, _file_available)) anchors = emulator_file_anchors(files) - for f, anchor, alias in zip(files, anchors, emulator_file_aliases(files, anchors)): - lines.extend( - _render_emulator_file( - f, profile, consumers, files, _file_available, anchor, name, - alias, - ) - ) + aliases = emulator_file_aliases(files, anchors) + cards = [ + _render_emulator_file(f, profile, consumers, files, _file_available, anchor, name, alias) + for f, anchor, alias in zip(files, anchors, aliases) + ] + parts = emulator_parts(name, files) + if not parts: + for card in cards: + lines.extend(card) + else: + lines.extend(_card_index(emu_name, files, anchors, aliases, parts)) + for number, (stem, entries) in enumerate(parts, 1): + part_pages[stem] = _emulator_part_page( + name, emu_name, number, parts, [cards[i] for i in entries], + [files[i] for i in entries]) if data_dirs: # The member tables are a file list with hashes: out of the search, @@ -4593,6 +4854,63 @@ def generate_emulator_page( "[When a game does not start](../wiki/troubleshooting.md)", ])) lines.extend([f"*Generated on {_timestamp()}*"]) + return {name: "\n".join(lines) + "\n", **part_pages} + + +def _card_index( + emu_name: str, files: list[dict], anchors: list[str], aliases: list[str], + parts: list[tuple[str, range]], +) -> list[str]: + """Every entry of a split emulator page, one row each, linked to its card. + + Each row holds the ids the card had on this page, so a link cited + before the split lands on the row, and site.js follows it to the card. + """ + page_of = {index: stem for stem, entries in parts for index in entries} + lines = [ + f"{emu_name} reads more files than one page holds: each entry below " + f"links to its card, on {_count(len(parts), 'page')} by profile order " + + ", ".join(f"[{number}]({stem}.md)" for number, (stem, _e) in enumerate(parts, 1)) + + ".", + "", + '
', + "", + "| File | Required | Category |", + "|------|----------|----------|", + ] + for index, (f, anchor, alias) in enumerate(zip(files, anchors, aliases)): + ids = "".join(f'' for a in (anchor, alias) if a) + shown = str(f.get("path") or f.get("name", "")) + lines.append( + f"| {ids}[{_code(shown)}]({page_of[index]}.md#{anchor}) " + f"| {'yes' if f.get('required') else 'no'} | {f.get('category', 'bios')} |" + ) + lines.extend(["", "
", ""]) + return lines + + +def _emulator_part_page( + name: str, emu_name: str, number: int, parts: list[tuple[str, range]], + cards: list[list[str]], files: list[dict], +) -> str: + """One page of a split emulator's cards.""" + first = str(files[0].get("path") or files[0].get("name", "")) + last = str(files[-1].get("path") or files[-1].get("name", "")) + others = ", ".join( + f"[{n}]({stem}.md)" for n, (stem, _e) in enumerate(parts, 1) if n != number) + lines = [ + f"# {emu_name} files, part {number} of {len(parts)}", + "", + f"Part of [{emu_name}]({name}.md), whose page has the profile, the install " + f"command and every entry. This part holds {_count(len(files), 'entry', 'entries')}, " + f"{_code(first)} to {_code(last)}, in the profile's order. The other parts: {others}.", + "", + "## Files", + "", + *(line for card in cards for line in card), + ] + lines.extend(_next_block([f"[{emu_name}]({name}.md), the profile and every entry"])) + lines.extend([f"*Generated on {_timestamp()}*"]) return "\n".join(lines) + "\n" @@ -5947,19 +6265,35 @@ def nav_emulators(profiles: dict) -> dict[str, dict]: def _emulator_nav(profiles: dict) -> list: - """Emulators in alphabetical groups ("A to C"), by the name a user knows.""" - groups: dict[str, list[tuple[str, str]]] = {} + """Emulators in alphabetical groups ("A to C"), then by initial. + + Material writes the open section of the navigation into every page: a + group of 96 emulators put 65 KB of links before each emulator page's + content. One initial is at most 60. + """ + groups: dict[str, dict[str, list[tuple[str, str]]]] = {} for key, profile in nav_emulators(profiles).items(): display = str(profile.get("emulator", key)) initial = display[:1].upper() letters = next((g for g in EMULATOR_NAV_GROUPS if initial in g), "") label = f"{letters[0]} to {letters[-1]}" if letters else "0 to 9" - groups.setdefault(label, []).append((display, f"emulators/{key}.md")) + sub = initial if letters else label + groups.setdefault(label, {}).setdefault(sub, []).append((display, f"emulators/{key}.md")) + + def entries(items: list[tuple[str, str]]) -> list[dict]: + return [{d: path} for d, path in sorted(items, key=lambda e: e[0].casefold())] + order = ["0 to 9"] + [f"{g[0]} to {g[-1]}" for g in EMULATOR_NAV_GROUPS] - return ["emulators/index.md"] + [ - {label: [{d: path} for d, path in sorted(groups[label], key=lambda e: e[0].casefold())]} - for label in order if label in groups - ] + nav: list = ["emulators/index.md"] + for label in order: + if label not in groups: + continue + subs = groups[label] + if len(subs) == 1: + nav.append({label: entries(next(iter(subs.values())))}) + else: + nav.append({label: [{sub: entries(subs[sub])} for sub in sorted(subs)]}) + return nav def _system_nav(manufacturers: dict) -> list: @@ -6174,14 +6508,8 @@ def _generate(args: argparse.Namespace) -> None: # report the same files. gap_report = build_emulator_gap_report(profiles, coverages, db, suppl_names) - print("Generating static API and bulk data exports...") - exports = generate_data_exports( - docs, db, coverages, profiles, stats, gap_report - ) - write_if_changed(str(docs / "data.md"), generate_data_page(exports) + REFERENCE_NEXT) - PAGE_DATA["data.md"] = dataset_structured_data(exports, db) - # Build system_id -> manufacturer page map (needed by all generators) + plan_emulator_pages(profiles) print("Building system cross-reference map...") manufacturers = _group_by_manufacturer(db) set_system_page_map( @@ -6189,25 +6517,6 @@ def _generate(args: argparse.Namespace) -> None: ) print(f" {len(_system_page_map)} system IDs mapped to pages") - export_reports = load_export_reports() - _EXPORTED.update(report["platform"] for report in export_reports) - - print("Generating platform pages...") - write_if_changed( - str(docs / "platforms" / "index.md"), - generate_platform_index(coverages, registry), - ) - for name, cov in coverages.items(): - # The emulators this platform runs, not every one of the system. - runs = resolve_platform_cores(cov["config"], profiles) - write_if_changed( - str(docs / "platforms" / f"{name}.md"), - generate_platform_page(name, cov, registry, { - emu: data for emu, data in emulator_files.items() if emu in runs - }), - ) - PAGE_DATA[f"platforms/{name}.md"] = platform_structured_data(name, cov) - print("Generating system pages...") prime_system_icons({ @@ -6222,16 +6531,55 @@ def _generate(args: argparse.Namespace) -> None: console_installs = system_install_choices(coverages) (docs / "files").mkdir(parents=True, exist_ok=True) write_if_changed(str(docs / "files" / "index.md"), generate_files_page()) + reset_console_pages() for mfr, consoles in manufacturers.items(): slug = mfr.lower().replace(" ", "-") - page = generate_system_page(mfr, consoles, consumers, console_installs.get(slug)) - PAGE_DATA[f"systems/{slug}.md"] = system_structured_data(slug, consoles) + pages = system_pages(mfr, consoles, consumers, console_installs.get(slug)) + for stem, (page, held) in pages.items(): + PAGE_DATA[f"systems/{stem}.md"] = system_structured_data(stem, held) + if stem != slug: + PART_PAGES.add(f"systems/{stem}.md") + write_if_changed(str(docs / "systems" / f"{stem}.md"), page) write_single_item( docs / "api" / "v1" / "systems" / f"{slug}.json", "systems", system_export_item(slug, mfr, consoles), str(db.get("generated_at") or _timestamp()), ) - write_if_changed(str(docs / "systems" / f"{slug}.md"), page) + clash = split_system_stems() & {_slug(m) for m in manufacturers} + if clash: + raise RuntimeError(f"a console page takes a manufacturer's address: {sorted(clash)}") + + print("Generating static API and bulk data exports...") + exports = generate_data_exports( + docs, db, coverages, profiles, stats, gap_report + ) + write_if_changed(str(docs / "data.md"), generate_data_page(exports) + REFERENCE_NEXT) + PAGE_DATA["data.md"] = dataset_structured_data(exports, db) + + export_reports = load_export_reports() + _EXPORTED.update(report["platform"] for report in export_reports) + + print("Generating platform pages...") + write_if_changed( + str(docs / "platforms" / "index.md"), + generate_platform_index(coverages, registry), + ) + for name, cov in coverages.items(): + # The emulators this platform runs, not every one of the system. + runs = resolve_platform_cores(cov["config"], profiles) + moved: dict[str, str] = {} + write_if_changed( + str(docs / "platforms" / f"{name}.md"), + generate_platform_page(name, cov, registry, { + emu: data for emu, data in emulator_files.items() if emu in runs + }, moved), + ) + for stem, page in moved.items(): + if stem in coverages: + raise RuntimeError(f"a system page takes the platform {stem}'s address") + PART_PAGES.add(f"platforms/{stem}.md") + write_if_changed(str(docs / "platforms" / f"{stem}.md"), page) + PAGE_DATA[f"platforms/{name}.md"] = platform_structured_data(name, cov) print("Generating emulator pages...") _DATA_MEMBERS.update(data_directory_members()) @@ -6264,12 +6612,18 @@ def _generate(args: argparse.Namespace) -> None: indent=2, sort_keys=True, ensure_ascii=False, default=str, ) + "\n", ) - page = generate_emulator_page( + pages = emulator_pages( name, profile, db, consumers, suppl_names, data_registry, data_dirs=page_data_directories(name, profiles), ) PAGE_DATA[f"emulators/{name}.md"] = emulator_structured_data(profile) - write_if_changed(str(docs / "emulators" / f"{name}.md"), page) + files = profile.get("files") or [] + for stem, entries in emulator_parts(name, files): + PAGE_DATA[f"emulators/{stem}.md"] = emulator_structured_data( + {**profile, "files": [files[i] for i in entries]}) + PART_PAGES.add(f"emulators/{stem}.md") + for stem, page in pages.items(): + write_if_changed(str(docs / "emulators" / f"{stem}.md"), page) print("Generating cross-reference page...") write_if_changed( @@ -6323,6 +6677,11 @@ def _generate(args: argparse.Namespace) -> None: print("Generating contributing page...") write_if_changed(str(docs / "contributing.md"), generate_contributing()) + # Swept before the decoration, which rewrites every page it finds: a + # page no generator produced any more (a console moved back onto its + # manufacturer's page) was decorated, recorded, and kept for ever. + stale = _sweep_generated(docs, GENERATED_DIRS) + print("Adding page metadata and structured data...") decorate_markdown_pages(docs) @@ -6421,19 +6780,17 @@ markdown_extensions: - tables - toc: permalink: true - # File cards are level 4 headings: the search lands on them, and a - # page of a hundred cards keeps a readable table of contents. + # Level 3 is the wiki's subsections. On pages of file cards, also level + # 3, the hook keeps the table of contents to level 2. toc_depth: 3 - pymdownx.details - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.keys -- pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format +# No mermaid fence: Material drew it in the browser with a script from a +# CDN. Diagrams are SVG files written by scripts/sitediagrams.py. +- pymdownx.superfences - pymdownx.tabbed: alternate_style: true plugins: @@ -6449,26 +6806,11 @@ validation: config_path, config_text = _mkdocs_config(Path(docs), mkdocs_static + nav_yaml) write_if_changed(str(config_path), config_text) - total_pages = ( - 1 # home - + 1 - + len(coverages) # platform index + detail - + 1 - + len(manufacturers) # system index + detail - + 1 # cross-reference - + 1 - + sum( - 1 for profile in profiles.values() - if profile.get("type") not in ("alias", "test") - ) # emulator detail pages (aliases/tests remain metadata-only) - + 1 # gap analysis - + 1 # which-pack - + len(list(Path("wiki").glob("*.md"))) # wiki pages copied verbatim - + 1 # generated wiki/data-model - + 1 # contributing - + 1 # data and API + # Counted on disk: the split pages made a sum of page kinds wrong. + total_pages = sum( + 1 for path in docs.rglob("*.md") + if not any(part in LOCAL_NOTE_DIRS for part in path.relative_to(docs).parts[:-1]) ) - stale = _sweep_generated(docs, GENERATED_DIRS) print(f"\nGenerated {total_pages} pages in {args.docs_dir}/") if stale: print(f"Removed {stale} page(s) nothing produced this run") @@ -6500,6 +6842,13 @@ from siterender import ( # noqa: E402,F401 _system_page_map, _system_link, set_system_page_map, + card_page, + console_href, + record_console_pages, + record_emulator_cards, + emulator_card_page, + reset_console_pages, + split_system_stems, _platform_link, _emulator_link, _fmt_size, diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py index 0028a3e1..11cef35d 100644 --- a/scripts/mkdocs_hooks.py +++ b/scripts/mkdocs_hooks.py @@ -10,8 +10,28 @@ from __future__ import annotations from typing import Any +# Pages made of file cards, level 3 headings by the hundred: their table of +# contents stops at level 2, the consoles of a system page, the Files of an +# emulator, the sections of a platform page (which listed every system id). +CARD_PAGES = ("systems/", "emulators/", "platforms/") + def on_page_markdown(markdown: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001 """Take lastmod from the page's own date, or leave it out.""" page.update_date = str(page.meta.get("date_modified") or "") return markdown + + +def _prune(items: list, depth: int) -> None: + for item in items: + if item.level >= depth: + item.children = [] + else: + _prune(item.children, depth) + + +def on_page_content(html: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001 + """Keep the table of contents of a page of cards to level 2.""" + if page.file.src_uri.startswith(CARD_PAGES): + _prune(page.toc.items, 2) + return html diff --git a/scripts/siterender.py b/scripts/siterender.py index f0b1cbc1..0683ad43 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -139,13 +139,75 @@ def _page_by_norm(sys_id: str) -> tuple[str, str] | None: return _norm_page_map.get(_norm_system_id(sys_id)) +# A manufacturer page too large for one page is a summary, each console on +# pages of its own: (slug, console) -> its page stems under systems/, and +# (slug, console, sha1) -> the stem holding that card. A console of a page +# kept whole has no entry, its section is on the manufacturer's page. +_console_pages: dict[tuple[str, str], list[str]] = {} +_card_pages: dict[tuple[str, str, str], str] = {} + + +def reset_console_pages() -> None: + _console_pages.clear() + _card_pages.clear() + + +def record_console_pages( + slug: str, console: str, stems: list[str], sha1s: list[list[str]] +) -> None: + """Where a split manufacturer page put one console: its page stems, in + order, and the sha1 of each card on each of them.""" + _console_pages[(slug, console)] = list(stems) + for stem, part in zip(stems, sha1s): + for sha1 in part: + _card_pages.setdefault((slug, console, sha1), stem) + + +def split_system_stems() -> set[str]: + """Every page stem of a console moved to a page of its own.""" + return {stem for stems in _console_pages.values() for stem in stems} + + +def console_href(slug: str, console: str, prefix: str = "") -> str: + """Where a console's files are: its own page, or its section of the + manufacturer's page (also where a console cut into parts lists them).""" + stems = _console_pages.get((slug, console)) + if stems and len(stems) == 1: + return f"{prefix}systems/{urllib.parse.quote(stems[0])}.md" + return f"{prefix}systems/{urllib.parse.quote(slug)}.md#{_slugify_anchor(console)}" + + +def card_page(path: str, sha1: str) -> str: + """The systems/ page stem holding the card of the copy at ``path`` + (bios///...).""" + parts = path.split("/") + slug = parts[1].lower().replace(" ", "-") + console = parts[2] if len(parts) > 2 else "" + return _card_pages.get((slug, console, sha1), slug) + + +# Emulator cards moved off a page too long for one: (emulator, anchor) -> +# the page stem under emulators/ that holds the card. +_emulator_card_pages: dict[tuple[str, str], str] = {} + + +def record_emulator_cards(emulator: str, stem: str, anchors: list[str]) -> None: + for anchor in anchors: + _emulator_card_pages[(emulator, anchor)] = stem + + +def emulator_card_page(emulator: str, anchor: str) -> str: + """The emulators/ page stem holding a card: the emulator's own page, or + the page its cards went to.""" + return _emulator_card_pages.get((emulator, anchor), emulator) + + def _system_link(sys_id: str, prefix: str = "") -> str: """Generate a markdown link to a system page with anchor.""" page = _system_page_map.get(sys_id) or _page_by_norm(sys_id) if page: slug, console = page - anchor = _slugify_anchor(console) - return f"[{sys_id}]({prefix}systems/{slug}.md#{anchor})" + return f"[{sys_id}]({console_href(slug, console, prefix)})" return sys_id def _field_text(key: object, value: object) -> str: diff --git a/tests/test_site_render.py b/tests/test_site_render.py index 8188ece8..a5b4d6f0 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -323,7 +323,7 @@ class TheSearchLandsOnTheCard(unittest.TestCase): entry = {"name": "scph5501.bin", "required": True} lines = generate_site._render_emulator_file( entry, {"files": [entry]}, None, [entry], lambda f: True, "file-scph5501-bin") - self.assertTrue(lines[2].startswith("#### `scph5501.bin` ")) + self.assertTrue(lines[2].startswith("### `scph5501.bin` ")) self.assertTrue(lines[2].endswith("{ #file-scph5501-bin .rb-file-title }")) def test_a_system_card_is_a_heading_with_its_anchor(self): @@ -332,7 +332,7 @@ class TheSearchLandsOnTheCard(unittest.TestCase): entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3} lines = generate_site._system_file_card(entry, FileConsumers({"files": {}})) - self.assertTrue(lines[2].startswith("#### `a.bin` ")) + self.assertTrue(lines[2].startswith("### `a.bin` ")) self.assertTrue(lines[2].endswith("{ #sha1-aaaaaaaaaaaa .rb-file-title }")) diff --git a/tests/test_site_weight.py b/tests/test_site_weight.py new file mode 100644 index 00000000..a524d7ed --- /dev/null +++ b/tests/test_site_weight.py @@ -0,0 +1,168 @@ +"""Pages a phone can open: split pages, light navigation, short tables of contents. + +systems/game-engines/ was one 10.5 MB page, emulators/mame/ 3 MB, and each +of 600 emulator pages carried 65 KB of navigation. A large page is now a +summary with pages of its own, and every address cited before the split +still lands: a console's heading stays on the summary, a card's sha1 anchor +is looked up by content, an emulator card's anchor sits on its row. +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site # noqa: E402 +import mkdocs_hooks # noqa: E402 +import siterender # noqa: E402 +from cross_reference import FileConsumers # noqa: E402 + + +def _file(name: str, index: int, console: str, maker: str = "Maker") -> dict: + sha1 = f"{index:012x}" + "0" * 28 + return {"name": name, "sha1": sha1, "md5": f"{index:032x}", "size": 10, + "path": f"bios/{maker}/{console}/{name}"} + + +class ASystemPageOverItsBudgetIsSplit(unittest.TestCase): + def setUp(self) -> None: + siterender.reset_console_pages() + self.addCleanup(siterender.reset_console_pages) + self.consoles = { + "Big": [_file(f"big{i:03d}.bin", i, "Big") for i in range(60)], + "Small": [_file("small.bin", 900, "Small")], + } + + def _pages(self, budget: int) -> dict: + return generate_site.system_pages( + "Maker", self.consoles, FileConsumers({"files": {}}), budget=budget) + + def test_a_page_within_its_budget_stays_whole(self): + pages = self._pages(10_000_000) + self.assertEqual(list(pages), ["maker"]) + self.assertIn("## Big", pages["maker"][0]) + + def test_the_largest_section_moves_and_is_cut_in_parts(self): + pages = self._pages(20_000) + summary = pages["maker"][0] + # Both headings stay on the summary: systems/maker/#big still lands. + self.assertIn("## Big", summary) + self.assertIn("## Small", summary) + # The small section keeps its cards in place. + self.assertIn("{ #sha1-" + f"{900:012x}", summary) + parts = [stem for stem in pages if stem.startswith("maker-big")] + self.assertGreater(len(parts), 1) + self.assertEqual(parts[0], "maker-big") + for stem in parts: + self.assertIn(f"]({stem}.md)", summary) + self.assertLessEqual(len(pages[stem][0]), 20_000 + 5_000) + self.assertIn("## Files", pages[stem][0]) + # Each card is on exactly one part, and the links follow it. + held = [f["sha1"] for stem in parts for f in pages[stem][1]["Big"]] + self.assertEqual(sorted(held), sorted(f["sha1"] for f in self.consoles["Big"])) + last = self.consoles["Big"][-1] + self.assertEqual(siterender.card_page(last["path"], last["sha1"]), parts[-1]) + small = self.consoles["Small"][0] + self.assertEqual(siterender.card_page(small["path"], small["sha1"]), "maker") + self.assertEqual(siterender.console_href("maker", "Big"), "systems/maker.md#big") + + def test_a_console_on_one_page_is_linked_directly(self): + self.consoles["Big"] = self.consoles["Big"][:12] + whole = self._pages(10**9)["maker"][0] + pages = self._pages(len(whole) - 1) + self.assertIn("maker-big", pages) + self.assertNotIn("maker-big-2", pages) + self.assertEqual(siterender.console_href("maker", "Big", "../"), + "../systems/maker-big.md") + + +class AnEmulatorWithTooManyCardsIsSplit(unittest.TestCase): + def test_parts_are_balanced_and_rows_keep_the_anchors(self): + files = [{"name": f"set{i:04d}.zip", "required": i % 2 == 0, "category": "bios_zip"} + for i in range(generate_site.EMULATOR_CARDS_PER_PAGE * 2 + 1)] + parts = generate_site.emulator_parts("big", files) + self.assertEqual([stem for stem, _ in parts], ["big-files-1", "big-files-2", "big-files-3"]) + sizes = [len(entries) for _, entries in parts] + self.assertLessEqual(max(sizes) - min(sizes), 1) + self.assertEqual(generate_site.emulator_parts("small", files[:3]), []) + + pages = generate_site.emulator_pages( + "big", {"emulator": "Big", "type": "standalone", "files": files}, {"files": {}}) + self.assertEqual(sorted(pages), ["big", "big-files-1", "big-files-2", "big-files-3"]) + main = pages["big"] + anchors = generate_site.emulator_file_anchors(files) + for anchor in anchors: + self.assertIn(f'', main) + self.assertIn('class="rb-card-index"', main) + self.assertNotIn(".rb-file-title", main) + self.assertIn(f"{{ #{anchors[0]} .rb-file-title }}", pages["big-files-1"]) + + def test_cards_are_linked_on_the_page_that_holds_them(self): + files = [{"name": f"set{i:04d}.zip"} for i in range(generate_site.EMULATOR_CARDS_PER_PAGE + 1)] + generate_site.plan_emulator_pages({"big": {"files": files}}) + anchors = generate_site.emulator_file_anchors(files) + self.assertEqual(siterender.emulator_card_page("big", anchors[-1]), "big-files-2") + self.assertEqual(siterender.emulator_card_page("other", "file-x"), "other") + + +class APlatformSystemOverItsBudgetMoves(unittest.TestCase): + def test_the_heading_stays_and_the_list_moves(self): + files = [{"name": f"f{i:04d}.zip", "status": "ok"} for i in range(40)] + config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files} + moved: dict[str, str] = {} + budget = generate_site.PLATFORM_SECTION_BUDGET + generate_site.PLATFORM_SECTION_BUDGET = 500 + try: + lines = generate_site._render_platform_systems( + {"arcade": files}, config, {}, ("deck", "Deck"), moved) + finally: + generate_site.PLATFORM_SECTION_BUDGET = budget + text = "\n".join(lines) + self.assertIn("{ #arcade }", text) + self.assertIn("](deck-arcade.md)", text) + self.assertNotIn("f0001.zip", text) + self.assertIn("f0001.zip", moved["deck-arcade"]) + self.assertIn("[Deck page](deck.md#arcade)", moved["deck-arcade"]) + + +class TheNavigationOpensOneInitial(unittest.TestCase): + def test_a_group_of_several_initials_is_nested(self): + profiles = { + key: {"emulator": name, "systems": ["s"]} + for key, name in (("a1", "Alpha"), ("b1", "Beta"), ("b2", "bravo"), ("z", "Zed")) + } + nav = generate_site._emulator_nav(profiles) + groups = {next(iter(g)): next(iter(g.values())) for g in nav[1:]} + self.assertEqual(groups["A to C"], [ + {"A": [{"Alpha": "emulators/a1.md"}]}, + {"B": [{"Beta": "emulators/b1.md"}, {"bravo": "emulators/b2.md"}]}, + ]) + self.assertEqual(groups["V to Z"], [{"Zed": "emulators/z.md"}]) + + +class TheTableOfContentsOfACardPageStopsAtLevelTwo(unittest.TestCase): + @staticmethod + def _item(level: int, children: list | None = None) -> SimpleNamespace: + return SimpleNamespace(level=level, children=children or []) + + def test_cards_leave_the_toc_and_wiki_pages_keep_level_three(self): + def page(uri: str) -> SimpleNamespace: + h2 = self._item(2, [self._item(3)]) + return SimpleNamespace(file=SimpleNamespace(src_uri=uri), + toc=SimpleNamespace(items=[self._item(1, [h2])])) + + for uri, kept in (("systems/sony.md", 0), ("platforms/retroarch.md", 0), + ("emulators/mame.md", 0), ("wiki/faq.md", 1)): + current = page(uri) + mkdocs_hooks.on_page_content("", current, None, None) + with self.subTest(uri=uri): + self.assertEqual(len(current.toc.items[0].children[0].children), kept) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 51bda600..f3ee044c 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -51,7 +51,7 @@ Every module in `tests/`, by the area it guards. | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | -| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture