Skip to content

Commit

Permalink
Feature: infer parent/child relationships between games (#542)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmercm authored Sep 21, 2023
1 parent c952f2c commit 0765e9d
Show file tree
Hide file tree
Showing 18 changed files with 505 additions and 94 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,12 @@ site/
# ROMs
*.7z
*.bin
*.cue
*.dvd
*.gb
*.gba
*.gbc
*.gdi
*.nes
*.rom
*.sfc
Expand Down
1 change: 1 addition & 0 deletions docs/alternatives.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ There are a few different popular ROM managers that have similar features:
| App: required setup steps | ✅ no setup required | ❌ requires "profile" setup per DAT | ⚠️ if specifying DAT & ROM dirs | ❌ requires per-DAT DB setup |
| DATs: supported formats | Logiqx XML, MAME ListXML, CMPro, HTGD SMDB ([DATs docs](input/dats.md)) | Logiqx XML, MAME ListXML, CMPro | Logiqx XML, MAME ListXML, CMPro, RomCenter, HTGD SMDB | Logiqx XML, CMPro, RomCenter |
| DATs: process multiple at once || ⚠️ via the batcher |||
| DATs: infer parent/clone info |||||
| DATs: built-in download manager ||| ⚠️ via [DatVault](https://www.datvault.com/) ||
| DATs: supports DAT URLs |||||
| DATs: create from files (dir2dat) |||||
Expand Down
26 changes: 25 additions & 1 deletion docs/input/dats.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,31 @@ Being able to know that many releases are actually the same game gives `igir` th

If you have the option to download "parent/clone" or "P/C" versions of DATs, you should always choose those.

## Arcade
### Parent/clone inference

One feature that sets `igir` apart from other ROM managers is its ability to infer parent/clone information when DATs don't provide it. For example, Redump DATs don't provide parent/clone information, which makes it much more difficult to create 1G1R sets.

For example, all of these Super Smash Bros. Melee releases should be considered the same game, even if a DAT doesn't provide proper information. If the releases are all considered the same game, then the `--single` option can be used in combination with [ROM preferences](../roms/filtering-preferences.md) to make a 1G1R set. `igir` is smart enough to understand that the only differences between these releases are the regions, languages, and revisions.

```text
Super Smash Bros. Melee (Europe) (En,Fr,De,Es,It)
Super Smash Bros. Melee (Korea) (En,Ja)
Super Smash Bros. Melee (USA) (En,Ja)
Super Smash Bros. Melee (USA) (En,Ja) (Rev 1)
Super Smash Bros. Melee (USA) (En,Ja) (Rev 2)
```

!!! note

It is unlikely that `igir` will ever be perfect with inferring parent/clone information. If you find an instance where `igir` made the wrong choice, please create a [GitHub issue](https://github.com/emmercm/igir/issues).

!!! tip

[Retool](https://github.com/unexpectedpanda/retool) is a DAT manipulation tool that has a set of hand-maintained [parent/clone lists](https://github.com/unexpectedpanda/retool-clonelists-metadata) to supplement common DAT groups such as No-Intro and Redump. This helps cover situations such as release titles in different languages that would be hard to group together automatically.

1G1R DATs made by Retool can be used seamlessly with `igir`. You won't need to supply the `--single` option or any [ROM preferences](../roms/filtering-preferences.md) for `igir`, as you would have already applied these preferences in Retool, but you can still supply [ROM filtering](../roms/filtering-preferences.md) options if desired.

## Arcade DATs

Building a ROM set that works with your _exact_ version of [MAME](https://www.mamedev.org/) or FinalBurn [Alpha](https://www.fbalpha.com/) / [Neo](https://github.com/finalburnneo/FBNeo) is necessarily complicated. Arcade machines vary wildly in hardware, they contain many more ROM chips than cartridge-based consoles, their ROM dumps are sometimes imperfect, and arcade emulators prefer "mostly working" emulation over perfect emulation.

Expand Down
2 changes: 1 addition & 1 deletion docs/roms/filtering-preferences.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ROM Filtering & Preference
# ROM Filtering & Preferences

`igir` offers many options for filtering as well as 1G1R preferences/priorities (when combined with the `--single` option).

Expand Down
13 changes: 6 additions & 7 deletions src/igir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import CandidatePostProcessor from './modules/candidatePostProcessor.js';
import CandidatePreferer from './modules/candidatePreferer.js';
import CandidateWriter from './modules/candidateWriter.js';
import DATFilter from './modules/datFilter.js';
import DATInferrer from './modules/datInferrer.js';
import DATGameInferrer from './modules/datGameInferrer.js';
import DATMergerSplitter from './modules/datMergerSplitter.js';
import DATParentInferrer from './modules/datParentInferrer.js';
import DATScanner from './modules/datScanner.js';
import DirectoryCleaner from './modules/directoryCleaner.js';
import FileIndexer from './modules/fileIndexer.js';
Expand Down Expand Up @@ -73,11 +74,7 @@ export default class Igir {
// Set up progress bar and input for DAT processing
const datProcessProgressBar = await this.logger.addProgressBar('Processing DATs', ProgressBarSymbol.PROCESSING, dats.length);
if (!dats.length) {
dats = new DATInferrer(datProcessProgressBar).infer(roms);
}

if (this.options.getSingle() && !dats.some((dat) => dat.hasParentCloneInfo())) {
throw new Error('No DAT contains parent/clone information, cannot process --single');
dats = new DATGameInferrer(datProcessProgressBar).infer(roms);
}

const datsToWrittenFiles = new Map<DAT, File[]>();
Expand All @@ -96,7 +93,9 @@ export default class Igir {
dat.getParents().length,
);

const mergedSplitDat = await new DATMergerSplitter(this.options, progressBar).merge(dat);
const datWithParents = await new DATParentInferrer(progressBar).infer(dat);
const mergedSplitDat = await new DATMergerSplitter(this.options, progressBar)
.merge(datWithParents);
const filteredDat = await new DATFilter(this.options, progressBar).filter(mergedSplitDat);

// Generate and filter ROM candidates
Expand Down
7 changes: 3 additions & 4 deletions src/modules/argumentsParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export default class ArgumentsParser {
.option('region-filter', {
group: groupRomFiltering,
alias: 'R',
description: `List of comma-separated regions to filter to (supported: ${Internationalization.REGIONS.join(', ')})`,
description: `List of comma-separated regions to filter to (supported: ${Internationalization.REGION_CODES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.split(','),
requiresArg: true,
Expand Down Expand Up @@ -444,7 +444,6 @@ export default class ArgumentsParser {
alias: 's',
description: 'Output only a single game per parent (1G1R) (required for all options below, requires DATs with parent/clone information)',
type: 'boolean',
implies: 'dat',
})
.option('prefer-verified', {
group: groupRomPriority,
Expand All @@ -470,7 +469,7 @@ export default class ArgumentsParser {
.option('prefer-region', {
group: groupRomPriority,
alias: 'r',
description: `List of comma-separated regions in priority order (supported: ${Internationalization.REGIONS.join(', ')})`,
description: `List of comma-separated regions in priority order (supported: ${Internationalization.REGION_CODES.join(', ')})`,
type: 'string',
coerce: (val: string) => val.split(','),
requiresArg: true,
Expand Down Expand Up @@ -514,7 +513,7 @@ export default class ArgumentsParser {
group: groupRomPriority,
description: 'Prefer parent ROMs over clones',
type: 'boolean',
implies: ['dat', 'single'],
implies: 'single',
})

.option('report-output', {
Expand Down
12 changes: 6 additions & 6 deletions src/modules/datInferrer.ts → src/modules/datGameInferrer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,19 @@ import Module from './module.js';
*
* This class will not be run concurrently with any other class.
*/
export default class DATInferrer extends Module {
export default class DATGameInferrer extends Module {
constructor(progressBar: ProgressBar) {
super(progressBar, DATInferrer.name);
super(progressBar, DATGameInferrer.name);
}

/**
* Infer DATs from input files.
* Infer {@link Game}s from input files.
*/
infer(romFiles: File[]): DAT[] {
this.progressBar.logInfo(`inferring DATs for ${romFiles.length.toLocaleString()} ROM${romFiles.length !== 1 ? 's' : ''}`);

const datNamesToRomFiles = romFiles.reduce((map, file) => {
const datName = DATInferrer.getDatName(file);
const datName = DATGameInferrer.getDatName(file);
const datRomFiles = map.get(datName) ?? [];
datRomFiles.push(file);
map.set(datName, datRomFiles);
Expand All @@ -38,7 +38,7 @@ export default class DATInferrer extends Module {
this.progressBar.logDebug(`inferred ${datNamesToRomFiles.size.toLocaleString()} DAT${datNamesToRomFiles.size !== 1 ? 's' : ''}`);

const dats = [...datNamesToRomFiles.entries()]
.map(([datName, datRomFiles]) => DATInferrer.createDAT(datName, datRomFiles));
.map(([datName, datRomFiles]) => DATGameInferrer.createDAT(datName, datRomFiles));

this.progressBar.logInfo('done inferring DATs');
return dats;
Expand All @@ -52,7 +52,7 @@ export default class DATInferrer extends Module {
const header = new Header({ name: datName });

const gameNamesToRomFiles = romFiles.reduce((map, file) => {
const gameName = DATInferrer.getGameName(file);
const gameName = DATGameInferrer.getGameName(file);
const gameRomFiles = map.get(gameName) ?? [];
gameRomFiles.push(file);
map.set(gameName, gameRomFiles);
Expand Down
214 changes: 214 additions & 0 deletions src/modules/datParentInferrer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import ProgressBar, { ProgressBarSymbol } from '../console/progressBar.js';
import DAT from '../types/dats/dat.js';
import Game from '../types/dats/game.js';
import LogiqxDAT from '../types/dats/logiqx/logiqxDat.js';
import Internationalization from '../types/internationalization.js';
import Module from './module.js';

/**
* Infer {@link Parent}s for all {@link DAT}s, even those that already have some parents.
*
* This class may be run concurrently with other classes.
*/
export default class DATParentInferrer extends Module {
constructor(progressBar: ProgressBar) {
super(progressBar, DATParentInferrer.name);
}

/**
* Infer {@link Parent}s from {@link Game}s.
*/
async infer(dat: DAT): Promise<DAT> {
this.progressBar.logInfo(`inferring parents for ${dat.getGames().length.toLocaleString()} game${dat.getGames().length !== 1 ? 's' : ''}`);

if (!dat.getGames().length) {
this.progressBar.logDebug(`${dat.getNameShort()}: no games to process`);
return dat;
}

await this.progressBar.setSymbol(ProgressBarSymbol.GENERATING);
await this.progressBar.reset(dat.getGames().length);

// Group games by their stripped names
const strippedNamesToGames = dat.getGames().reduce((map, game) => {
let strippedGameName = game.getName();
strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName);
strippedGameName = DATParentInferrer.stripGameVariants(strippedGameName);
map.set(strippedGameName, [...(map.get(strippedGameName) ?? []), game]);
return map;
}, new Map<string, Game[]>());
const groupedGames = [...strippedNamesToGames.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, games]) => games);

const newGames = groupedGames.flatMap((games) => {
if (games.length <= 1) {
// Only one game, there can't be a parent
return games;
}

return DATParentInferrer.electParent(games);
});

const groupedDat = new LogiqxDAT(dat.getHeader(), newGames);
this.progressBar.logDebug(`${groupedDat.getNameShort()}: grouped to ${groupedDat.getParents().length.toLocaleString()} parent${groupedDat.getParents().length !== 1 ? 's' : ''}`);

this.progressBar.logInfo('done inferring parents');
return groupedDat;
}

private static stripGameRegionAndLanguage(name: string): string {
return name
// ***** Regions *****
.replace(new RegExp(`\\(((${Internationalization.REGION_CODES.join('|')})[,+-]? ?)+\\)`, 'i'), '')
.replace(new RegExp(`\\(((${Internationalization.REGION_NAMES.join('|')})[,+-]? ?)+\\)`, 'i'), '')
// ***** Languages *****
.replace(new RegExp(`\\(((${Internationalization.LANGUAGES.join('|')})[,+-]? ?)+\\)`, 'i'), '')
// ***** Cleanup *****
.replace(/ +/g, ' ')
.trim();
}

private static stripGameVariants(name: string): string {
return name
// ***** Retail types *****
.replace(/\(Alt( [a-z0-9. ]*)?\)/i, '')
.replace(/\([^)]*Collector's Edition\)/i, '')
.replace(/\(Extra Box\)/i, '')
.replace(/\(Fukkokuban\)/i, '') // "reprint"
.replace(/\([^)]*Genteiban\)/i, '') // "limited edition"
.replace(/\(Limited[^)]+Edition\)/i, '')
.replace(/\(Limited Run Games\)/i, '')
.replace(/\(Made in [^)]+\)/i, '')
.replace(/\(Major Wave\)/i, '')
.replace(/\((Midway Classics)\)/i, '')
.replace(/\([^)]*Premium [^)]+\)/i, '')
.replace(/\([^)]*Preview Disc\)/i, '')
.replace(/\(Recalled\)/i, '')
.replace(/\(Renkaban\)/i, '') // "cheap edition"
.replace(/\(Reprint\)/i, '')
.replace(/\(Rerelease\)/i, '')
.replace(/\(Rev[a-z0-9. ]*\)/i, '')
.replace(/\([^)]*Seisanban\)/i, '') // "production version"
.replace(/\(Shotenban\)/i, '') // "bookstore edition"
.replace(/\(Special Pack\)/i, '')
.replace(/\([^)]+ the Best\)/i, '')
.replace(/\([^)]*Taiouban[^)]*\)/i, '') // "compatible version"
.replace(/\([^)]*Tokubetsu-?ban[^)]*\)/i, '') // "special edition"
// ***** Non-retail types *****
.replace(/\([0-9]{4}-[0-9]{2}-[0-9]{2}\)/, '') // YYYY-MM-DD
.replace(/\(Aftermarket[a-z0-9. ]*\)/i, '')
.replace(/\(Alpha[a-z0-9. ]*\)/i, '')
.replace(/\(Beta[a-z0-9. ]*\)/i, '')
.replace(/\(Build [a-z0-9. ]+\)/i, '')
.replace(/\(Bung\)/i, '')
.replace(/\(Debug\)/i, '')
.replace(/\(Demo[a-z0-9. -]*\)|\([^)]*Taikenban[^)]*\)/i, '') // "trial"
.replace(/\(Hack\)/i, '')
.replace(/\(Homebrew[a-z0-9. ]*\)/i, '')
.replace(/\(Not for Resale\)/i, '')
.replace(/\(PD\)/i, '') // "public domain"
.replace(/\(Pirate[a-z0-9. ]*\)/i, '')
.replace(/\(Proto[a-z0-9. ]*\)/i, '')
.replace(/\([^)]*Sample[a-z0-9. ]*\)/i, '')
.replace(/\(Spaceworld[a-z0-9. ]*\)/i, '')
.replace(/\(Test[a-z0-9. ]*\)/i, '')
.replace(/\(Unl[a-z0-9. ]*\)/i, '')
.replace(/\(v[0-9.]+[a-z]*\)/i, '')
.replace(/\(Version [0-9.]+[a-z]*\)/i, '')
// ***** Good Tools *****
.replace(/\[!\]/, '')
.replace(/\[b[0-9]*\]/, '')
.replace(/\[bf\]/, '')
.replace(/\[c\]/, '')
.replace(/\[f[0-9]*\]/, '')
.replace(/\[h[a-zA-Z90-9+]*\]/, '')
.replace(/\[MIA\]/, '')
.replace(/\[o[0-9]*\]/, '')
.replace(/\[!p\]/, '')
.replace(/\[p[0-9]*\]/, '')
.replace(/\[t[0-9]*\]/, '')
.replace(/\[T[+-][^\]]+\]/, '')
.replace(/\[x\]/, '')
// ***** TOSEC *****
.replace(/\((demo|demo-kiosk|demo-playable|demo-rolling|demo-slideshow)\)/, '') // demo
.replace(/\([0-9x]{4}(-[0-9x]{2}(-[0-9x]{2})?)?\)/, '') // YYYY-MM-DD
.replace(/\((CGA|EGA|HGC|MCGA|MDA|NTSC|NTSC-PAL|PAL|PAL-60|PAL-NTSC|SVGA|VGA|XGA)\)/i, '') // video
.replace(/\(M[0-9]+\)/, '') // language
.replace(/\((CW|CW-R|FW|GW|GW-R|LW|PD|SW|SW-R)\)/i, '') // copyright
.replace(/\((alpha|beta|preview|pre-release|proto)\)/i, '') // development
.replace(/(\[(cr|f|h|m|p|t|tr|o|u|v|b|a|!)( [a-z0-9.+ -]+)?\])+/i, '')
// ***** Console-specific *****
// Nintendo - Game Boy
.replace(/\(SGB Enhanced\)/i, '')
// Nintendo - Game Boy Color
.replace(/\(GB Compatible\)/i, '')
// Nintendo - GameCube
.replace(/\(GameCube\)/i, '')
// Nintendo - Super Nintendo Entertainment System
.replace(/\(NP\)/i, '') // "Nintendo Power"
// Sega - Mega Drive / Genesis
.replace(/\(MP\)/i, '') // "MegaPlay version"
// Sega - Sega/Mega CD
.replace(/\(RE-?[0-9]*\)/, '')
// Sony - PlayStation 1
.replace(/\(EDC\)/i, '') // copy protection
.replace(/\(PSone Books\)/i, '')
.replace(/\((SCES|SCUS|SLES|SLUS)-[0-9]+\)/i, '')
// ***** Cleanup *****
.replace(/ +/g, ' ')
.trim();
// ***** EXPLICITLY LEFT ALONE *****
// (Bonus Disc .*)
// (Disc [0-9A-Z])
// (Mega-CD 32X) / (Sega CD 32X)
}

private static electParent(games: Game[]): Game[] {
// Index games by their name without the region and language
const strippedNamesToGames = games.reduce((map, game) => {
let strippedGameName = game.getName();
strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName);
if (!map.has(strippedGameName)) {
// If there is a conflict after stripping the region & language, then we know the two games
// only differ by region & language. Assume the first one seen in the DAT should be the
// parent.
map.set(strippedGameName, game);
}
return map;
}, new Map<string, Game>());

return games.map((game, idx) => {
if (game.getParent()) {
// Game has a parent, respect it
return game;
}

// Search for this game's retail parent.
// Retail games do not have variants such as "(Demo)", so if we fully strip the game name and
// find a match, then we have reasonable confidence that match is this game's parent.
let strippedGameName = game.getName();
strippedGameName = DATParentInferrer.stripGameRegionAndLanguage(strippedGameName);
strippedGameName = DATParentInferrer.stripGameVariants(strippedGameName);
const retailParent = strippedNamesToGames.get(strippedGameName);
if (retailParent) {
if (retailParent.hashCode() === game.hashCode()) {
// This game is the parent
return game;
}
return new Game({ ...game, cloneOf: retailParent.getName() });
}

// Assume this game's non-retail parent.
// If we got here, then we know these games share the same fully-stripped name. Assume the
// first game seen in the DAT should be the parent.
// The only danger with this assumption is it will affect `--prefer-parent`, but that's not
// likely a commonly used option.
if (idx === 0) {
// This game is the parent
return game;
}
return new Game({ ...game, cloneOf: games[0].getName() });
});
}
}
2 changes: 1 addition & 1 deletion src/types/dats/dat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default abstract class DAT {
* Does any {@link Game} in this {@link DAT} have clone information.
*/
hasParentCloneInfo(): boolean {
return this.getGames().some((game) => game.isClone());
return this.getGames().some((game) => game.getParent());
}

getName(): string {
Expand Down
Loading

0 comments on commit 0765e9d

Please sign in to comment.