ROM Shelf works from one folder on your computer. Pick the start that fits you. You can switch folders any time from ☰ Menu.
I have ROMs
Choose the folder that holds them: one folder per system, or one big pile. ROM Shelf puts them on shelves by system, finds duplicates and builds batches to copy to a handheld. Nothing is moved or deleted until you confirm.
Starting fresh
Make a new, empty folder anywhere (call it “ROM Shelf”) and choose it. You get the complete game lists for every system to browse, rate and star into a wishlist, and your picks are saved in that folder.
Just looking
No folder needed. Walk through video game history: a timeline of every system, series histories, genre waves and a printable book.
Works in Chrome or Edge on a computer. Files are read on this computer and never uploaded. ROM Shelf keeps its notes in a visible “ROM Shelf Data” folder inside the folder you choose, so favorites, ratings and batches travel with it.
HXA.tlRender()); }
}
function openHistory(tab, opts){ bandTab(tab || HIST.tab, opts); }
// a tab opens the band on that page; clicking the page that is already showing folds the band, like the triangle
function histGo(h){ if(HXA.TL.on && HIST.btab === h){ HXA.toggleTimeline(false); return; } if(h === 'all') HXA.openFind({}); else bandTab(h); }
document.addEventListener('click', e => { if(e.target.closest('#tlBand .hh')){ HXA.toggleTimeline(); return; } const b = e.target.closest('.htabs [data-h]'); if(b){ e.preventDefault(); histGo(b.dataset.h); } });
$('#btnHistW').onclick = () => openHistory('series');
// The history pages ask ROM Shelf what you own, and send you back to a game or system
window.RSG = {
idx: null, stamp: -1, xidx: null, xs: -1,
get ver(){ return S.stamp; },
hasLibrary(){ return S.games.length > 0 && !S.browse; },
norm(s){ return lc(String(s)).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').trim().replace(/^(the|a|an) /, ''); },
// loose: the title and anything that starts with it ("Tetris" finds "Tetris DX")
owned(q){
if(!this.hasLibrary()) return [];
if(this.stamp !== S.stamp || !this.idx){ const m = new Map(); S.games.forEach(g => { if(g && g.title && !g.missing){ const n = this.norm(g.title); if(!m.has(n)) m.set(n, g.title.replace(/\s*\(.*$/, '')); } }); this.idx = [...m]; this.stamp = S.stamp; }
const n = this.norm(q); if(!n) return [];
const out = new Set(); for(const [k, t] of this.idx){ if(k === n || k.startsWith(n + ' ')) out.add(t); if(out.size > 12) break; }
return [...out];
},
// strict: the same game, matching a full title or a subtitle ("A Link to the Past")
exact(q){
if(!this.hasLibrary()) return [];
if(this.xs !== S.stamp || !this.xidx){
const full = new Map(), sub = new Map(), put = (m, k, g) => { if(!m.has(k)) m.set(k, []); m.get(k).push(g); };
S.games.forEach(g => { if(g.missing || g.isBios || !visible(g)) return; const k = titleKeys(g.title); put(full, k[0], g); if(k[1]) put(sub, k[1], g); });
this.xidx = { full, sub }; this.xs = S.stamp;
}
const k = titleKeys(q), seen = new Set(), out = [], X = this.xidx;
[X.full.get(k[0]), X.sub.get(k[0]), k[1] && X.full.get(k[1])].forEach(l => (l || []).forEach(g => { if(seen.has(g.uid)) return; seen.add(g.uid); out.push({ uid: g.uid, label: g.title.replace(/\s*\(.*$/, '') + ' (' + (SYS[g.system] || SYS.other).short + ')' }); }));
return out;
},
sysFor(name){ const id = sysFromFolder(name); return id && SYS[id] && S.sys.some(e => e.id === id) ? id : ''; },
open(o){ const g = o && S.games.find(x => x.uid === o.uid); this.close(); if(g) HXA.jumpToGame(g); },
openSys(id){ this.close(); S.sys = entriesFor(); const i = S.sys.findIndex(e => e.id === id); if(i >= 0){ if(S.view === 'welcome') showView('systems'); openSystem(i); } },
find(q){ HXA.openFind({ q }); },
// a year on the series or genre pages: list everything from that year (All games, with the year's systems and milestones)
year(y){ y = +y; if(y) HXA.openFind({ year: y }); },
close(){ HXA.toggleTimeline(false); }
};
async function forgetFolder(){ await idb.set('kv', 'library', null); await idb.set('kv', 'media', null); setStartFolder(null); toast('Forgotten. ROM Shelf will ask for a folder next time.', 4000); }
/* ===================== batches ===================== */
let batchRev = 0; const touchBatch = () => { batchRev++; };
const activeBatch = () => S.user.batches.find(b => b.id === S.user.active) || null;
const newBatch = (name, profile = 'custom') => {
const p = PROFILES[profile], b = { id: 'b' + Date.now().toString(36) + Math.floor(Math.random() * 99), name, profile, layout: p.layout, items: [], exclude: [], systems: [], maxFileMB: p.maxFileMB, capacityGB: p.capacityGB,
auto: { on: false, favOnly: false, minRating: 0, worksOnly: false, perSystem: 0, defining: false, cat: '' }, opts: { art: true, snap: true, video: true, gamelist: true, m3u: true } };
b.systems = profileSystems(profile); return b;
};
function profileSystems(profile){ const p = PROFILES[profile] || PROFILES.custom; return SYSTEMS.filter(s => s.id !== 'other' && s.tier <= p.maxTier && !p.exclude.includes(s.id)).map(s => s.id); }
const resCache = new Map();
function resolveBatch(b){
const hit = resCache.get(b.id); if(hit && hit.rev === batchRev) return hit.res;
const best = new Map(); S.games.forEach(g => { if(g.isBest && !(g.isBios && S.cfg.hideBios)) best.set(g.uid, g); });
const allowed = new Set(b.systems), picked = new Map(), excluded = [], ex = new Set(b.exclude || []);
const why = g => !allowed.has(g.system) ? 'Not in this batch’s systems (' + (SYS[g.system] || SYS.other).short + ')' : (b.maxFileMB && g.size > b.maxFileMB * 1048576) ? 'Larger than ' + b.maxFileMB + ' MB (' + fmtSize(g.size) + ')' : gstate(g) === 'broken' ? 'Marked broken' : '';
for(const uid of b.items){ const g = best.get(uid); if(!g) continue; const r = why(g); if(r) excluded.push({ g, reason: r }); else picked.set(uid, { g, src: 'manual' }); }
let presetInfo = null;
if(b.preset && PRESETS[b.preset]){ // a batch made from a list of titles
presetInfo = presetResolve(b.preset, allowed, best);
presetInfo.games.forEach(g => { if(ex.has(g.uid) || picked.has(g.uid)) return; const r = why(g); if(r) excluded.push({ g, reason: r }); else picked.set(g.uid, { g, src: 'list' }); });
}
if(b.auto.on){
let total = [...picked.values()].reduce((n, x) => n + x.g.size, 0); const cap = b.capacityGB ? b.capacityGB * 2 ** 30 : Infinity, per = new Map();
picked.forEach(x => per.set(x.g.system, (per.get(x.g.system) || 0) + 1));
const cands = [...best.values()].filter(g => !picked.has(g.uid) && !ex.has(g.uid) && !g.identicalTo && !why(g) && allowed.has(g.system));
const fl = g => (b.auto.favOnly ? isFav(g) : true) && rating(g) >= (b.auto.minRating || 0) && (b.auto.worksOnly ? gstate(g) === 'works' : true) && (!b.auto.defining || isDefining(g)) && (!b.auto.cat || catOf(g) === b.auto.cat);
let pool = cands.filter(fl); if(b.auto.defining){ const keep = new Set(dedupeDefining(pool.filter(isDefining)).map(g => g.uid)); pool = pool.filter(g => !isDefining(g) || keep.has(g.uid)); }
const ranked = pool.sort((a, c) => (isFav(c) - isFav(a)) || (isDefining(c) - isDefining(a)) || (rating(c) - rating(a)) || ((gstate(c) === 'works') - (gstate(a) === 'works')) || (c.score - a.score) || natural(a.sortKey, c.sortKey));
for(const g of ranked){
if(b.auto.perSystem && (per.get(g.system) || 0) >= b.auto.perSystem) continue;
if(total + g.size > cap) continue;
picked.set(g.uid, { g, src: 'auto' }); total += g.size; per.set(g.system, (per.get(g.system) || 0) + 1);
}
}
for(const uid of ex) picked.delete(uid);
const games = [...picked.values()].map(x => Object.assign(x.g, {})), src = new Map([...picked].map(([k, v]) => [k, v.src]));
games.sort((a, c) => natural(a.sortKey, c.sortKey));
const res = { games, excluded, src, size: games.reduce((n, g) => n + g.size, 0), preset: presetInfo };
resCache.set(b.id, { rev: batchRev, res }); return res;
}
function inBatch(g){ const b = activeBatch(); if(!b) return false; return resolveBatch(b).games.some(x => x.uid === g.uid); }
function updateBatchChip(){} // the counts that used to sit in the top bar are worked out when the menu opens
function menuCounts(){
const oc = S.games.length ? organizeCount() : { total: 0, roots: 0, loose: 0 }, nd = [...S.groups.values()].filter(x => x.length > 1).length, sv = nd ? potentialSavings() : { now: 0, max: 0 }, bt = activeBatch();
return { oc, nd, sv, bt, bn: bt ? resolveBatch(bt).games.length : 0 };
}
function toggleBatch(g){
if(!g) return; if(S.virtual){ toast('Batches need your ROM folder. This is a read-only view.', 3500); return; } if(g.missing){ toast('You do not have this game yet, so it cannot go in a batch.', 3500); return; }
let b = activeBatch(); if(!b){ b = newBatch('My batch'); S.user.batches.push(b); S.user.active = b.id; }
const res = resolveBatch(b), has = res.games.some(x => x.uid === g.uid);
if(has){ b.items = b.items.filter(u => u !== g.uid); if(res.src.get(g.uid) === 'auto' || res.src.get(g.uid) === 'list'){ b.exclude = b.exclude || []; if(!b.exclude.includes(g.uid)) b.exclude.push(g.uid); } toast('Removed from “' + b.name + '”'); }
else { if(!b.items.includes(g.uid)) b.items.push(g.uid); b.exclude = (b.exclude || []).filter(u => u !== g.uid); const r = resolveBatch(b); const stillOut = !r.games.some(x => x.uid === g.uid); toast(stillOut ? 'Added, but this batch’s settings leave it out: ' + (r.excluded.find(x => x.g.uid === g.uid) || { reason: 'excluded' }).reason : 'Added to “' + b.name + '”'); }
touchBatch(); saveUser(); updateBatchChip(); renderList(); scheduleDetail(0); if($('#drawer').classList.contains('open')) renderDrawer(); if(S.view === 'systems') renderCarousel();
}
/* ===================== drawer ===================== */
function toggleDrawer(open){ const d = $('#drawer'); const o = open === undefined ? !d.classList.contains('open') : open; d.classList.toggle('open', o); if(o) renderDrawer(); }
function renderDrawer(){
const d = $('#drawer'), sc = d.querySelector('.scroll') ? d.querySelector('.scroll').scrollTop : 0, b = activeBatch();
let h = '
Batches
';
if(!S.games.length) h += '
Open a ROM folder first.
';
else {
h += '
' +
'
' +
'
' +
'
' +
'
';
if(b){
const r = resolveBatch(b), cap = b.capacityGB ? b.capacityGB * 2 ** 30 : 0, pct = cap ? Math.min(100, r.size / cap * 100) : 0, prof = PROFILES[b.profile];
const bySys = new Map(); r.games.forEach(g => bySys.set(g.system, (bySys.get(g.system) || 0) + 1));
h += '
Picks one best version of each game. Order: favorites, then star rating, then games you marked working, then region and revision. Stops when the card is full. Games marked broken are always skipped.
' : '
Off: the batch contains only what you add with Enter.
Artwork: ' + (b.opts.art || b.opts.snap ? withLocal + ' games have local art; the rest are fetched from the libretro library when it has them' + (S.cfg.onlineArt ? '' : ' (online art is off in Settings)') : 'not included') + '. Videos: ' + (b.opts.video ? vids + ' found in your linked folder' : 'not included') + '.
' +
(b.layout === 'onion' ? '
OnionOS style folder names are typical, not guaranteed. Open the exported Roms folder next to your card’s own to confirm the names match.
' : '') + '
' +
'
Files that already exist at the destination with the same size are skipped, so you can run it again to resume.
Emulator installs ' + ld.games.length + ' games exist in more than one install. These installs are kept intact, because taking single files out would break them.
Install sizes: ' + ld.installs.map(([r, n]) => '' + esc(r) + ' ' + fmtSize(n)).join(', ') + '. To free the space, keep the install you use and delete the other folder yourself.
';
} else if(C.tab === 'exact'){
if(!C.exact){
$('#cleanLead').textContent = 'Finds files with identical contents even when their names differ. It compares sizes first, then reads the files, so it can take a while on a big library.';
h = '
' + S.games.length.toLocaleString() + ' releases to compare.
';
} else {
$('#cleanLead').textContent = C.exact.length ? C.exact.length + ' sets of identical files. Same size and same contents (large files are compared by samples from the start, middle and end).' : 'No identical files found.';
h = C.exact.slice(0, C.show).map(g => '
').join('');
}
} else if(C.tab === 'junk'){
$('#cleanLead').textContent = junk.length ? junk.length + ' files look like leftovers: bad dumps, tiny stubs, betas and demos that have no final release, and games outside your region list or marked broken.' : 'No junk or leftovers found.';
h = junk.slice(0, C.show).map(j => '
'; body.scrollTop = sc;
const mv = buildMoves(), by = k => new Set(mv.filter(m => m.kind === k).map(m => m.g.id)).size;
const aside = new Set(mv.filter(m => m.kind !== 'organize').map(m => m.g.id)), asideBytes = [...aside].reduce((n, id) => n + S.byId.get(id).size, 0);
$('#cleanSum').textContent = (aside.size ? aside.size + ' games (' + fmtSize(asideBytes) + ') to move aside' : 'Nothing to move aside') + (by('organize') ? ' · ' + by('organize') + ' to organize' : '') + (aside.size ? ' [' + [['ver', 'versions'], ['exact', 'identical'], ['junk', 'junk']].map(([k, t]) => by(k) ? by(k) + ' ' + t : '').filter(Boolean).join(', ') + ']' : '');
$('#cleanGo').disabled = !mv.length || C.busy; $('#cleanGo').textContent = mv.length ? 'Move ' + mv.length + ' files' : 'Nothing to move'; $('#cleanSh').disabled = $('#cleanPs').disabled = !mv.length;
}
$('#cLvl').addEventListener('input', e => { C.level = +e.target.value; S.cfg.cleanLevel = C.level; saveCfg(); C.on.clear(); renderClean(); });
$('#dlgClean .tabs').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; C.tab = b.dataset.tab; C.show = 150; $$('#dlgClean .tabs button').forEach(x => x.classList.toggle('on', x === b)); renderClean(); });
$('#cleanBody').addEventListener('change', e => {
const t = e.target;
if(t.dataset.mv !== undefined) C.on.set(t.dataset.mv, t.checked);
else if(t.dataset.xon !== undefined) C.on.set('x' + t.dataset.xon, t.checked);
else if(t.dataset.xkeep){ const [g, i] = t.dataset.xkeep.split(':').map(Number); C.exKeep.set(g, i); }
else if(t.dataset.cleanup !== undefined){ C.alsoCleanup = t.checked; }
else if(t.id === 'oBase'){ S.cfg.romBase = t.value; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); }
else if(t.id === 'oNames'){ S.cfg.folderNames = t.value; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); }
else if(t.dataset.ron !== undefined) C.on.set('r:' + t.dataset.ron, t.checked);
else if(t.dataset.art){ C.art[t.dataset.art] = t.checked; }
else if(t.id === 'artLay'){ C.artLayout = t.value; }
else if(t.dataset.son) C.strayOn.set(t.dataset.son, t.checked);
else if(t.dataset.sto){ C.strayTo.set(t.dataset.sto, t.value); C.strayOn.set(t.dataset.sto, t.value !== 'other'); }
renderClean();
});
$('#cleanBody').addEventListener('click', e => {
if(e.target.id === 'cleanMore'){ C.show += 150; renderClean(); }
if(e.target.id === 'cleanScan') scanIdentical();
if(e.target.id === 'oBaseAuto'){ delete S.cfg.romBase; saveCfg(); C.on = new Map([...C.on].filter(([k]) => !k.startsWith('r:'))); renderClean(); return; }
if(e.target.id === 'artGo') saveArtInLibrary();
if(e.target.id === 'artStop'){ exp.cancel = true; }
const of = e.target.closest('[data-of]'); if(of){ const g = S.byId.get(of.dataset.of); if(g) openGameFolder(g); return; }
const mk = e.target.closest('[data-mk]'); if(mk){ const k = mk.dataset.mk, i = k.lastIndexOf(':'); const uid = k.slice(0, i), idx = +k.slice(i + 1); C.keep.set(uid, idx); (S.groups.get(uid) || []).forEach(g => C.on.delete(g.id)); renderClean(); }
});
/* identical-file scan */
async function sampledHash2(f){
const sz = f.size, L = 2 * 2 ** 20;
const blob = await f.handle.getFile(); let buf;
if(sz <= 128 * 2 ** 20) buf = new Uint8Array(await blob.arrayBuffer());
else { const parts = [blob.slice(0, L), blob.slice(Math.floor(sz / 2 - L / 2), Math.floor(sz / 2 + L / 2)), blob.slice(sz - L, sz)]; const ab = await Promise.all(parts.map(p => p.arrayBuffer())); buf = new Uint8Array(L * 3); ab.forEach((a, i) => buf.set(new Uint8Array(a), i * L)); }
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', buf)); return sz + ':' + [...d].slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function scanIdentical(){
C.busy = true; renderClean();
const main = g => g.files.reduce((a, b) => b.size > a.size ? b : a), bySize = new Map();
S.games.filter(g => g.size > 0 && !g.locked && !g.isFolder).forEach(g => push(bySize, main(g).size, g));
const sets = [...bySize.values()].filter(a => a.length > 1), total = sets.reduce((n, a) => n + a.length, 0); let done = 0; const found = [];
for(const set of sets){
const hs = new Map();
for(const g of set){ try{ push(hs, await sampledHash2(main(g)), g); }catch(e){} done++; if(done % 3 === 0){ const b = $('#scanBar'), t = $('#scanTxt'); if(b) b.style.width = (done / total * 100) + '%'; if(t) t.textContent = done + ' of ' + total + ' files compared'; await tick(); } }
for(const items of hs.values()) if(items.length > 1){ items.sort((a, b) => (b.score - a.score) || (b.isBest - a.isBest) || a.primary.path.length - b.primary.path.length); found.push({ i: found.length, items }); }
}
S.games.forEach(g => { g.identicalTo = null; });
found.forEach(grp => grp.items.slice(1).forEach(g => { g.identicalTo = grp.items[0]; }));
C.on = new Map([...C.on].filter(([k]) => !k.startsWith('x')));
C.exact = found.sort((a, b) => b.items[0].size * b.items.length - a.items[0].size * a.items.length).map((g, i) => ({ ...g, i })); C.exKeep = new Map(C.exact.map(g => [g.i, 0]));
C.busy = false; renderClean();
}
/* ===================== system folders: rename, merge and place ===================== */
function systemRootOf(e){
const segs = e.dir.split('/').filter(Boolean);
for(let i = segs.length - 1; i >= 0; i--){ const a = sysFromFolder(segs[i]); if(a) return { sys: a, root: segs.slice(0, i + 1).join('/') }; }
return null;
}
// If every system folder already sits inside one parent (like roms/), keep that parent instead of hoisting everything to the top.
function romBase(){
if(S.cfg.romBase !== undefined && S.cfg.romBase !== null) return String(S.cfg.romBase).replace(/^\/+|\/+$/g, '');
return suggestedBase();
}
function suggestedBase(){
const parents = new Map(); let total = 0;
for(const g of S.games){ if(g.isBios) continue; const r = systemRootOf(g.primary); if(r){ const p = dirOf(r.root); parents.set(p, (parents.get(p) || 0) + g.size); total += g.size; } }
const top = [...parents].sort((a, b) => b[1] - a[1])[0];
return top && top[0] && top[1] >= total * 0.35 ? top[0] : '';
}
// Handheld SD cards use their own folder names (FC, SFC, PS, FIFTYTWOHUNDRED…). Renaming those would hide the games from the device.
function detectDeviceStyle(){
if(/miyoo|ambernic|anbernic|onion|retroid|muos|arkos|spruce|minui|\brg\d{2,3}/i.test(S.rootName || '')) return true;
const cnt = new Map(); let total = 0;
for(const g of S.games){ if(g.isBios) continue; const r = systemRootOf(g.primary); total++; if(r){ const e = cnt.get(r.root) || { sys: r.sys, n: 0 }; e.n++; cnt.set(r.root, e); } }
let codeRoots = 0, codeGames = 0;
for(const [root, e] of cnt){ const nm = baseOf(root); if(/^[A-Z0-9]{2,}$/.test(nm) && lc(nm) !== lc((SYS[e.sys] && SYS[e.sys].es) || e.sys)){ codeRoots++; codeGames += e.n; } }
return codeRoots >= 3 && total > 0 && codeGames / total >= 0.6; // mostly device-style names: this IS a card, so keep them
}
const effScheme = () => { const s = S.cfg.folderNames || 'auto'; return s === 'auto' ? (S.deviceStyle ? 'keep' : 'es') : s; };
function computeRoots(){
const m = new Map(), base = romBase();
for(const g of S.games){ if(g.isBios || g.locked) continue; const r = systemRootOf(g.primary); if(!r) continue; if(!m.has(r.root)) m.set(r.root, { root: r.root, sys: r.sys, games: [] }); m.get(r.root).games.push(g); }
const list = [...m.values()];
if(effScheme() === 'keep'){
// keep every folder name. If one system is spread over several folders, offer (but do not preselect) merging into the biggest.
const main = new Map(); for(const r of list){ const cur = main.get(r.sys); if(!cur || r.games.length > cur.games.length) main.set(r.sys, r); }
return list.map(x => { const mn = main.get(x.sys), merge = mn !== x; return { ...x, to: merge ? mn.root : x.root, fine: !merge, min: merge ? 99 : 0, key: 'r:' + x.root }; }).sort((a, b) => natural(a.root, b.root));
}
const wanted = sys => (base ? base + '/' : '') + sysFolderName(sys);
const existing = new Map(); for(const r of list) if(lc(r.root) === lc(wanted(r.sys)) && !existing.has(r.sys)) existing.set(r.sys, r.root);
return list.map(x => { const to = existing.get(x.sys) || wanted(x.sys); return { ...x, to, fine: lc(x.root) === lc(to), min: 0, key: 'r:' + x.root }; }).sort((a, b) => natural(a.root, b.root));
}
function folderFor(sys){
const r = computeRoots().find(x => x.sys === sys); if(r) return r.to;
const base = romBase(); return (base ? base + '/' : '') + sysFolderName(sys);
}
function organizeCount(){
const roots = computeRoots().filter(r => !r.fine).length, loose = S.games.filter(g => !folderSystem(g.primary) && !g.isBios).length;
return { roots, loose, total: roots + loose };
}
function renderOrganize(){
const scheme = S.cfg.folderNames || 'auto', eff = effScheme(), roots = computeRoots(), base = romBase(), todo = roots.filter(r => !r.fine);
const explain = { auto: S.deviceStyle ? 'This looks like a handheld’s SD card (folders like FC, SFC, PS). Its folder names are kept so the device still finds its games.' : 'Short names used by EmulationStation, ES-DE, RetroPie, Batocera and most frontends.',
keep: 'Leaves every existing system folder name alone. Best for a device card. If one system is spread over several folders you can tick to merge them.',
onion: 'OnionOS codes such as FC, SFC, GBA and PS, which is what Miyoo Mini firmware looks for.', es: 'Short names used by EmulationStation, ES-DE, RetroPie, Batocera, OnionOS and most handheld firmware. Recommended. This page still shows the full system names while you browse.',
proper: 'Readable names such as “Sony PlayStation”. Easy to read in a file manager, but frontends that expect short names will not find them on their own.',
retroarch: 'The names RetroArch uses for its playlists and thumbnails, such as “Sony - PlayStation”. RetroArch itself does not require any folder naming.' }[scheme];
$('#cleanLead').textContent = (todo.length || C.stray.length) ? todo.length + (eff === 'keep' ? ' system folders can be merged, ' : ' system folders to rename or merge, ') + C.stray.length + ' loose games to place. Files are moved, never deleted.' : 'Every game is already inside a system folder with the right name.';
const counts = new Map(); S.games.filter(g => !g.isBios && g.system !== 'other').forEach(g => counts.set(g.system, (counts.get(g.system) || 0) + 1));
const lines = [(base || S.rootName) + '/']; (eff === 'keep' ? roots.map(r => [r.sys, r.games.length, baseOf(r.root)]) : [...counts].map(([id, n]) => [id, n, sysFolderName(id)])).slice(0, 6).forEach(([id, n, nm], i) => { lines.push(' ' + nm + '/ ' + n + ' games'); if(i === 0){ lines.push(' Game Name (USA).ext'); lines.push(' gamelist.xml'); lines.push(' media/images media/snaps media/videos'); } });
if(counts.size > 6) lines.push(' … ' + (counts.size - 6) + ' more systems');
const bySys = new Map(); roots.forEach(r => { if(!bySys.has(r.sys)) bySys.set(r.sys, []); bySys.get(r.sys).push(r); });
const spread = [...bySys].filter(([, l]) => l.length > 1).map(([s, l]) => (SYS[s] ? SYS[s].short : s) + ' (' + l.length + ' folders)');
const baseUI = eff === 'keep' ? '' : '
' +
'
Suggested: ' + esc(suggestedBase() || 'the top folder') + ', where most of your games already are.' + (spread.length ? ' These systems are spread over several folders and will be merged into one each: ' + esc(spread.slice(0, 12).join(', ')) + (spread.length > 12 ? ', …' : '') + '.' : '') + '
';
let h = baseUI + '
' +
'
' + esc(explain) + '
' + esc(lines.join('\n')) + '
';
h += '
System folders
' + (roots.map(r => r.fine ?
'
✓' + esc(r.root) + '/ ' + r.games.length + ' games · already named correctly
' :
'
' + esc(r.root) + '/ → ' + esc(r.to) + '/' + r.games.length + ' games, plus any artwork and gamelist inside
After the folders look right, save box art, screenshots, videos and a gamelist.xml into each system folder, so every system is self-contained. Missing pictures are fetched when the libretro library has them. An existing gamelist.xml is never overwritten.