Skip to content

Commit f3520f2

Browse files
authored
Feature: merge discs option (#1505)
1 parent 1b04b09 commit f3520f2

File tree

10 files changed

+530
-4
lines changed

10 files changed

+530
-4
lines changed

docs/advanced/internals.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Igir runs these steps in the following order:
1313
4. Then for each DAT:
1414
- Parent/clone information is inferred if the DAT has none (see [DATs docs](../dats/processing.md#parentclone-inference))
1515
- Parent/clone ROMs sets are merged or split (`--merge-roms <type>`) (see [arcade docs](../usage/arcade.md))
16+
- Multi-disc games are merged (`--merge-discs`) (see [ROM set docs](../roms/sets.md))
1617
- ROMs in the DAT are filtered to only those desired (`--filter-*` options) (see [filtering & preference docs](../roms/filtering-preferences.md))
1718
- ROMs in the DAT are filtered to the preferred clone (`--single`, see [filtering & preference docs](../roms/filtering-preferences.md#preferences-for-1g1r))
1819
- Input files are matched to ROMs in the DAT (see [matching docs](../roms/matching.md))

docs/roms/sets.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# ROM Sets
2+
3+
"Sets" here refers to the collection of all ROM files for a game. The options here change what is included or excluded from sets, how sets can be combined, and what is permissible in sets.
4+
5+
## ROM set merge types
6+
7+
The `--merge-roms <mode>` option is used to reduce storage requirements when working with MAME and other arcade DATs that supply [parent/clone](../dats/introduction.md#parentclone-pc-dats) information. See the [arcade docs](../usage/arcade.md#rom-set-merge-types) for information on this option.
8+
9+
## Merging multi-disc games
10+
11+
Most DAT groups that catalog optical media-based consoles (e.g. PS1, Dreamcast, GameCube) consider different discs of a multi-disc game to be separate "games," with no relation between them other than having a similar name. This is because ROM managers may not process games unless all of its ROM files are present, but there may be bonus discs that you don't care about for storage reasons.
12+
13+
The `--merge-discs` option will merge these separate games of a multi-disc game. The option relies on well-named files in formats like these:
14+
15+
- **Redump-style:**
16+
17+
```text
18+
Final Fantasy IX (USA) (Disc 1)
19+
Final Fantasy IX (USA) (Disc 2)
20+
Final Fantasy IX (USA) (Disc 3)
21+
Final Fantasy IX (USA) (Disc 4)
22+
23+
Metal Gear Solid - The Twin Snakes (USA) (Disc 1)
24+
Metal Gear Solid - The Twin Snakes (USA) (Disc 2)
25+
```
26+
27+
- **TOSEC-style:**
28+
29+
```text
30+
Skies of Arcadia v1.002 (2000)(Sega)(US)(Disc 1 of 2)[!]
31+
Skies of Arcadia v1.002 (2000)(Sega)(US)(Disc 2 of 2)[!]
32+
33+
Panzer Dragoon Saga v1.000 (1998)(Sega)(PAL)(Disc 1 of 4)[!]
34+
Panzer Dragoon Saga v1.000 (1998)(Sega)(PAL)(Disc 2 of 4)[!]
35+
Panzer Dragoon Saga v1.000 (1998)(Sega)(PAL)(Disc 3 of 4)[!]
36+
Panzer Dragoon Saga v1.000 (1998)(Sega)(PAL)(Disc 4 of 4)[!]
37+
```
38+
39+
!!! note
40+
41+
This option doesn't require you to supply DATs with the [`--dat <path>` option](../dats/processing.md#scanning-for-dats), but doing so will greatly increase the chance of the option working as intended.
42+
43+
<!-- TODO(cemmer): document allow-excess-sets -->
44+
45+
<!-- TODO(cemmer): document allow-incomplete-sets -->

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ nav:
9292
- input/reading-archives.md
9393
- ROM Processing:
9494
- roms/matching.md
95+
- roms/sets.md
9596
- roms/filtering-preferences.md
9697
- roms/headers.md
9798
- roms/patching.md

src/igir.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import CandidatePostProcessor from './modules/candidates/candidatePostProcessor.
2020
import CandidateValidator from './modules/candidates/candidateValidator.js';
2121
import CandidateWriter from './modules/candidates/candidateWriter.js';
2222
import DATCombiner from './modules/dats/datCombiner.js';
23+
import DATDiscMerger from './modules/dats/datDiscMerger.js';
2324
import DATFilter from './modules/dats/datFilter.js';
2425
import DATGameInferrer from './modules/dats/datGameInferrer.js';
2526
import DATMergerSplitter from './modules/dats/datMergerSplitter.js';
@@ -486,10 +487,15 @@ export default class Igir {
486487
}
487488

488489
private processDAT(progressBar: ProgressBar, dat: DAT): DAT {
489-
const datWithParents = new DATParentInferrer(this.options, progressBar).infer(dat);
490-
const mergedSplitDat = new DATMergerSplitter(this.options, progressBar).merge(datWithParents);
491-
const filteredDat = new DATFilter(this.options, progressBar).filter(mergedSplitDat);
492-
return new DATPreferer(this.options, progressBar).prefer(filteredDat);
490+
return [
491+
(dat: DAT): DAT => new DATParentInferrer(this.options, progressBar).infer(dat),
492+
(dat: DAT): DAT => new DATMergerSplitter(this.options, progressBar).merge(dat),
493+
(dat: DAT): DAT => new DATDiscMerger(this.options, progressBar).merge(dat),
494+
(dat: DAT): DAT => new DATFilter(this.options, progressBar).filter(dat),
495+
(dat: DAT): DAT => new DATPreferer(this.options, progressBar).prefer(dat),
496+
].reduce((processedDat, processor) => {
497+
return processor(processedDat);
498+
}, dat);
493499
}
494500

495501
private async generateCandidates(

src/modules/argumentsParser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,11 @@ export default class ArgumentsParser {
608608
}
609609
return true;
610610
})
611+
.option('merge-discs', {
612+
group: groupRomSet,
613+
description: 'Merge multi-disc games into one game',
614+
type: 'boolean',
615+
})
611616
.option('exclude-disks', {
612617
group: groupRomSet,
613618
description: 'Exclude CHD disks in DATs from processing & writing',

src/modules/dats/datDiscMerger.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import ProgressBar, { ProgressBarSymbol } from '../../console/progressBar.js';
2+
import DAT from '../../types/dats/dat.js';
3+
import Game from '../../types/dats/game.js';
4+
import LogiqxDAT from '../../types/dats/logiqx/logiqxDat.js';
5+
import Options from '../../types/options.js';
6+
import Module from '../module.js';
7+
8+
/**
9+
* Merge multi-disc {@link Game}s in a {@link DAT} into one game.
10+
*/
11+
export default class DATDiscMerger extends Module {
12+
private readonly options: Options;
13+
14+
constructor(options: Options, progressBar: ProgressBar) {
15+
super(progressBar, DATDiscMerger.name);
16+
this.options = options;
17+
}
18+
19+
/**
20+
* Merge {@link Game}s.
21+
*/
22+
merge(dat: DAT): DAT {
23+
if (!this.options.getMergeDiscs()) {
24+
this.progressBar.logTrace(`${dat.getName()}: not merging discs`);
25+
return dat;
26+
}
27+
28+
if (dat.getGames().length === 0) {
29+
this.progressBar.logTrace(`${dat.getName()}: no games to merge`);
30+
return dat;
31+
}
32+
33+
this.progressBar.logTrace(
34+
`${dat.getName()}: merging ${dat.getGames().length.toLocaleString()} game${dat.getGames().length !== 1 ? 's' : ''}`,
35+
);
36+
this.progressBar.setSymbol(ProgressBarSymbol.DAT_MERGE_SPLIT);
37+
this.progressBar.reset(dat.getGames().length);
38+
39+
const groupedGames = this.groupGames(dat.getGames());
40+
const newDat = new LogiqxDAT(dat.getHeader(), groupedGames);
41+
this.progressBar.logTrace(
42+
`${newDat.getName()}: merged to ${newDat.getGames().length.toLocaleString()} game${newDat.getGames().length !== 1 ? 's' : ''}`,
43+
);
44+
45+
this.progressBar.logTrace(`${newDat.getName()}: done merging`);
46+
return newDat;
47+
}
48+
49+
private groupGames(games: Game[]): Game[] {
50+
const gameNamesToGames = games.reduce((map, game) => {
51+
let gameNameStripped = game
52+
.getName()
53+
// Redump
54+
.replace(/ ?\(Disc [0-9]+\)/i, '')
55+
// TOSEC
56+
.replace(/ ?\(Disc [0-9]+ of [0-9]+\)/i, '');
57+
58+
if (gameNameStripped !== game.getName()) {
59+
// The game is a multi-disc game, strip some additional patterns
60+
gameNameStripped = gameNameStripped
61+
// TOSEC
62+
.replace(/\[[0-9]+S\]/, '') // Dreamcast ring code
63+
.trim();
64+
}
65+
66+
if (!map.has(gameNameStripped)) {
67+
map.set(gameNameStripped, [game]);
68+
} else {
69+
map.get(gameNameStripped)?.push(game);
70+
}
71+
72+
return map;
73+
}, new Map<string, Game[]>());
74+
75+
return [...gameNamesToGames.entries()].flatMap(([gameName, games]) => {
76+
if (games.length === 1) {
77+
return games[0];
78+
}
79+
80+
const roms = games.flatMap((game) => game.getRoms());
81+
82+
const romNamesToCount = roms.reduce((map, rom) => {
83+
map.set(rom.getName(), (map.get(rom.getName()) ?? 0) + 1);
84+
return map;
85+
}, new Map<string, number>());
86+
const duplicateRomNames = [...romNamesToCount.entries()]
87+
.filter(([, count]) => count > 1)
88+
.map(([romName]) => romName)
89+
.sort();
90+
if (duplicateRomNames.length > 1) {
91+
this.progressBar.logError(
92+
`${gameName}: can't group discs, games have duplicate ROM filenames:\n${duplicateRomNames.map((name) => ` ${name}`).join('\n')}`,
93+
);
94+
return games;
95+
}
96+
97+
return new Game({
98+
name: gameName,
99+
rom: roms,
100+
});
101+
});
102+
}
103+
}

src/modules/dats/datParentInferrer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export default class DATParentInferrer extends Module {
209209
.replace(/\(NP\)/i, '') // "Nintendo Power"
210210
// Sega - Dreamcast
211211
.replace(/\[([0-9A-Z ]+(, )?)+\]$/, '') // TOSEC boxcode
212+
.replace(/\[[0-9]+S\]/, '') // TOSEC ring code
212213
.replace(
213214
/\[(compilation|data identical to retail|fixed version|keyboard|limited edition|req\. microphone|scrambled|unscrambled|white label)\]/gi,
214215
'',

src/types/options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface OptionsProps {
111111
readonly removeHeaders?: string[];
112112

113113
readonly mergeRoms?: string;
114+
readonly mergeDiscs?: boolean;
114115
readonly excludeDisks?: boolean;
115116
readonly allowExcessSets?: boolean;
116117
readonly allowIncompleteSets?: boolean;
@@ -259,6 +260,8 @@ export default class Options implements OptionsProps {
259260

260261
readonly mergeRoms?: string;
261262

263+
readonly mergeDiscs: boolean;
264+
262265
readonly excludeDisks: boolean;
263266

264267
readonly allowExcessSets: boolean;
@@ -431,6 +434,7 @@ export default class Options implements OptionsProps {
431434
this.removeHeaders = options?.removeHeaders;
432435

433436
this.mergeRoms = options?.mergeRoms;
437+
this.mergeDiscs = options?.mergeDiscs ?? false;
434438
this.excludeDisks = options?.excludeDisks ?? false;
435439
this.allowExcessSets = options?.allowExcessSets ?? false;
436440
this.allowIncompleteSets = options?.allowIncompleteSets ?? false;
@@ -1068,6 +1072,10 @@ export default class Options implements OptionsProps {
10681072
return MergeMode[mergeMode as keyof typeof MergeMode];
10691073
}
10701074

1075+
getMergeDiscs(): boolean {
1076+
return this.mergeDiscs;
1077+
}
1078+
10711079
getExcludeDisks(): boolean {
10721080
return this.excludeDisks;
10731081
}

test/modules/argumentsParser.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ describe('options', () => {
216216
expect(options.getSymlinkRelative()).toEqual(false);
217217

218218
expect(options.getMergeRoms()).toEqual(MergeMode.FULLNONMERGED);
219+
expect(options.getMergeDiscs()).toEqual(false);
219220
expect(options.getExcludeDisks()).toEqual(false);
220221
expect(options.getAllowExcessSets()).toEqual(false);
221222
expect(options.getAllowIncompleteSets()).toEqual(false);
@@ -2723,6 +2724,37 @@ describe('options', () => {
27232724
).toEqual(MergeMode.SPLIT);
27242725
});
27252726

2727+
it('should parse "merge-discs"', () => {
2728+
expect(
2729+
argumentsParser.parse([...dummyCommandAndRequiredArgs, '--merge-discs']).getMergeDiscs(),
2730+
).toEqual(true);
2731+
expect(
2732+
argumentsParser
2733+
.parse([...dummyCommandAndRequiredArgs, '--merge-discs', 'true'])
2734+
.getMergeDiscs(),
2735+
).toEqual(true);
2736+
expect(
2737+
argumentsParser
2738+
.parse([...dummyCommandAndRequiredArgs, '--merge-discs', 'false'])
2739+
.getMergeDiscs(),
2740+
).toEqual(false);
2741+
expect(
2742+
argumentsParser
2743+
.parse([...dummyCommandAndRequiredArgs, '--merge-discs', '--merge-discs'])
2744+
.getMergeDiscs(),
2745+
).toEqual(true);
2746+
expect(
2747+
argumentsParser
2748+
.parse([...dummyCommandAndRequiredArgs, '--merge-discs', 'false', '--merge-discs', 'true'])
2749+
.getMergeDiscs(),
2750+
).toEqual(true);
2751+
expect(
2752+
argumentsParser
2753+
.parse([...dummyCommandAndRequiredArgs, '--merge-discs', 'true', '--merge-discs', 'false'])
2754+
.getMergeDiscs(),
2755+
).toEqual(false);
2756+
});
2757+
27262758
it('should parse "exclude-disks"', () => {
27272759
expect(() =>
27282760
argumentsParser.parse([...dummyCommandAndRequiredArgs, '--exclude-disks']),

0 commit comments

Comments
 (0)