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