Skip to content

Commit 1f69303

Browse files
authored
Fix: match .cue/.bin CHDs for merged multi-disc games (#2312)
1 parent 77619e9 commit 1f69303

6 files changed

Lines changed: 582 additions & 16 deletions

File tree

src/models/dats/mergedDiscGame.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { GameProps } from './game.js';
2+
import Game from './game.js';
3+
4+
export interface MergedDiscGameProps extends GameProps {
5+
readonly subGames: Game[];
6+
}
7+
8+
/**
9+
* A game merged from two or more disc-based games. It keeps the original per-disc games as
10+
* first-class sub-games while still exposing their flattened ROMs to every other consumer.
11+
*/
12+
export default class MergedDiscGame extends Game {
13+
private readonly subGames: Game[];
14+
15+
constructor(props: MergedDiscGameProps) {
16+
// Seed the inherited `roms` field from the same ROM instances the sub-games hold, so that an
17+
// object spread (`{ ...mergedDiscGame }`) and serialization both see every ROM. getRoms() below
18+
// keeps the sub-games authoritative.
19+
super({
20+
...props,
21+
roms: props.subGames.flatMap((game) => game.getRoms()),
22+
});
23+
this.subGames = props.subGames;
24+
}
25+
26+
/**
27+
* Return the original per-disc games this merged game was built from.
28+
*/
29+
getSubGames(): Game[] {
30+
return this.subGames;
31+
}
32+
33+
/**
34+
* Return a new merged game with some different properties, preserving the sub-games.
35+
*/
36+
override withProps(props: GameProps): MergedDiscGame {
37+
// Note: any `roms` in `props` is ignored — the constructor always re-derives `roms` from
38+
// `subGames`. Pipeline callers only ever change props such as `cloneOf`.
39+
return new MergedDiscGame({
40+
...this,
41+
...props,
42+
subGames: this.subGames,
43+
});
44+
}
45+
}

src/modules/candidates/candidateGenerator.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type FileFactory from '../../factories/fileFactory.js';
99
import type DAT from '../../models/dats/dat.js';
1010
import Disk from '../../models/dats/disk.js';
1111
import type Game from '../../models/dats/game.js';
12+
import MergedDiscGame from '../../models/dats/mergedDiscGame.js';
1213
import type ROM from '../../models/dats/rom.js';
1314
import type Archive from '../../models/files/archives/archive.js';
1415
import ArchiveEntry from '../../models/files/archives/archiveEntry.js';
@@ -289,6 +290,17 @@ export default class CandidateGenerator extends Module {
289290
return new Map();
290291
}
291292

293+
if (game instanceof MergedDiscGame) {
294+
// Resolve each disc independently so every disc is the single-archive case the code already
295+
// handles correctly (including pulling an unknown .cue from the same CHD that holds its .bins).
296+
return this.findOptimalInputFilesForMergedDiscGame(
297+
dat,
298+
game,
299+
romsAndInputFiles,
300+
indexedFiles,
301+
);
302+
}
303+
292304
const archiveWithEveryRom = this.findArchiveFileWithEveryRomForGame(
293305
dat,
294306
game,
@@ -310,6 +322,71 @@ export default class CandidateGenerator extends Module {
310322
);
311323
}
312324

325+
/**
326+
* Resolve input files for a {@link MergedDiscGame} one sub-game at a time. Any ROM not resolved
327+
* to a single containing archive falls back to its first matched input file.
328+
*/
329+
private findOptimalInputFilesForMergedDiscGame(
330+
dat: DAT,
331+
game: MergedDiscGame,
332+
romsAndInputFiles: [ROM, File[]][],
333+
indexedFiles: IndexedFiles,
334+
): Map<ROM, File> {
335+
// All intermediate matching is keyed by a stable ROM name + hash code string rather than by ROM
336+
// instance, so it survives any earlier reinstantiation
337+
const entryKey = (rom: ROM): string => `${rom.getName()}|${rom.hashCode()}`;
338+
const entriesByKey = new Map<string, [ROM, File[]]>(
339+
romsAndInputFiles.map((romAndInputFiles) => [
340+
entryKey(romAndInputFiles[0]),
341+
romAndInputFiles,
342+
]),
343+
);
344+
345+
// Resolve each sub-game's single containing archive, keyed by entryKey
346+
const filesByKey = new Map<string, File>();
347+
for (const subGame of game.getSubGames()) {
348+
const subGameRoms = subGame.getRoms();
349+
const subGameRomsAndInputFiles = subGameRoms
350+
.map((subGameRom) => entriesByKey.get(entryKey(subGameRom)))
351+
.filter((entry) => entry !== undefined);
352+
if (subGameRomsAndInputFiles.length === 0) {
353+
continue;
354+
}
355+
356+
const archiveWithEveryRom = this.findArchiveFileWithEveryRomForGame(
357+
dat,
358+
subGame,
359+
subGameRoms,
360+
subGameRomsAndInputFiles,
361+
indexedFiles,
362+
);
363+
if (archiveWithEveryRom === undefined) {
364+
continue;
365+
}
366+
for (const [rom, inputFile] of archiveWithEveryRom) {
367+
filesByKey.set(entryKey(rom), inputFile);
368+
}
369+
}
370+
371+
const resolved = new Map<ROM, File>();
372+
for (const [rom, inputFiles] of romsAndInputFiles) {
373+
const inputFile = filesByKey.get(entryKey(rom)) ?? inputFiles.at(0);
374+
if (inputFile !== undefined) {
375+
resolved.set(rom, inputFile);
376+
}
377+
}
378+
return resolved;
379+
}
380+
381+
/**
382+
* Find a single input {@link Archive} that contains every one of a {@link Game}'s {@link ROM}s, and
383+
* return a map from each ROM to its matching entry within that archive. Preferring one archive for
384+
* the whole game avoids output-path conflicts when raw-copying and avoids leaving archives partially
385+
* used when zipping. Returns `undefined` when extracting (the source archive doesn't matter) or when
386+
* no single archive holds every ROM, leaving the caller to fall back to per-ROM matching. ROMs with
387+
* no matching entry are omitted from the returned map, so it is always a `Map<ROM, File>` of only
388+
* resolved ROMs.
389+
*/
313390
private findArchiveFileWithEveryRomForGame(
314391
dat: DAT,
315392
game: Game,
@@ -455,7 +532,7 @@ export default class CandidateGenerator extends Module {
455532
// An Archive was found, use that as the only possible input file
456533
// For each of this Game's ROMs, find the matching ArchiveEntry from this Archive
457534
return new Map(
458-
romsAndInputFiles.map(([rom, inputFiles]) => {
535+
romsAndInputFiles.flatMap(([rom, inputFiles]) => {
459536
const archiveEntries = inputFiles.filter(
460537
(inputFile) =>
461538
inputFile.getFilePath() === archiveWithEveryRom.getFilePath() &&
@@ -484,7 +561,7 @@ export default class CandidateGenerator extends Module {
484561
?.find((file) => file.getExtractedFilePath().toLowerCase().endsWith('.cue'));
485562
}
486563

487-
return [rom, archiveEntry as ArchiveEntry<Archive>];
564+
return archiveEntry === undefined ? [] : [[rom, archiveEntry]];
488565
}),
489566
);
490567
}

src/modules/dats/datDiscMerger.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import path from 'node:path';
33
import type ProgressBar from '../../console/progressBar.js';
44
import { ProgressBarSymbol } from '../../console/progressBar.js';
55
import type DAT from '../../models/dats/dat.js';
6-
import Game from '../../models/dats/game.js';
6+
import type Game from '../../models/dats/game.js';
7+
import MergedDiscGame from '../../models/dats/mergedDiscGame.js';
78
import type Options from '../../models/options.js';
89
import IntlUtil from '../../utils/intlUtil.js';
910
import Module from '../module.js';
@@ -69,21 +70,22 @@ export default class DATDiscMerger extends Module {
6970
.filter(([, count]) => count > 1)
7071
.map(([romName]) => romName)
7172
.toSorted();
72-
if (duplicateRomNames.length > 1) {
73-
// De-conflict the filenames by adding a subfolder of the original game's name
74-
const deconflictedRoms = games.flatMap((game) =>
75-
game.getRoms().map((rom) => rom.withName(path.join(game.getName(), rom.getName()))),
76-
);
77-
return new Game({
78-
name: gameName,
79-
roms: deconflictedRoms,
80-
discMerged: true,
81-
});
82-
}
8373

84-
return new Game({
74+
const subGames =
75+
duplicateRomNames.length > 1
76+
? // De-conflict the filenames by adding a subfolder of the original game's name
77+
games.map((game) =>
78+
game.withProps({
79+
roms: game
80+
.getRoms()
81+
.map((rom) => rom.withName(path.join(game.getName(), rom.getName()))),
82+
}),
83+
)
84+
: games;
85+
86+
return new MergedDiscGame({
8587
name: gameName,
86-
roms: roms,
88+
subGames,
8789
discMerged: true,
8890
});
8991
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Game from '../../../src/models/dats/game.js';
2+
import MergedDiscGame from '../../../src/models/dats/mergedDiscGame.js';
3+
import ROM from '../../../src/models/dats/rom.js';
4+
import SingleValueGame from '../../../src/models/singleValueGame.js';
5+
6+
const discOne = new Game({
7+
name: 'Game (Disc 1)',
8+
roms: [
9+
new ROM({ name: 'Game (Disc 1).cue', size: 97, crc32: '11111111' }),
10+
new ROM({ name: 'Game (Disc 1).bin', size: 100, crc32: '22222222' }),
11+
],
12+
});
13+
const discTwo = new Game({
14+
name: 'Game (Disc 2)',
15+
roms: [
16+
new ROM({ name: 'Game (Disc 2).cue', size: 97, crc32: '33333333' }),
17+
new ROM({ name: 'Game (Disc 2).bin', size: 200, crc32: '44444444' }),
18+
],
19+
});
20+
21+
describe('getSubGames', () => {
22+
it('should return the sub-games it was constructed with', () => {
23+
const merged = new MergedDiscGame({ name: 'Game', subGames: [discOne, discTwo] });
24+
expect(merged.getSubGames()).toEqual([discOne, discTwo]);
25+
});
26+
});
27+
28+
describe('getRoms', () => {
29+
it('should return the flattened sub-game ROM instances', () => {
30+
const merged = new MergedDiscGame({ name: 'Game', subGames: [discOne, discTwo] });
31+
expect(merged.getRoms()).toEqual([...discOne.getRoms(), ...discTwo.getRoms()]);
32+
});
33+
34+
it('should seed the inherited roms field so an object spread sees the flattened ROMs', () => {
35+
const merged = new MergedDiscGame({ name: 'Game', subGames: [discOne, discTwo] });
36+
// Several call sites build a SingleValueGame via `new SingleValueGame({ ...game })`, which
37+
// reads the raw `roms` field, not getRoms(). The constructor must seed `super` from the
38+
// sub-games for that spread to carry every ROM.
39+
const singleValueGame = new SingleValueGame({ ...merged });
40+
expect(singleValueGame.getRoms()).toEqual([...discOne.getRoms(), ...discTwo.getRoms()]);
41+
});
42+
});
43+
44+
describe('withProps', () => {
45+
it('should return a MergedDiscGame that preserves sub-games and applies the new props', () => {
46+
const merged = new MergedDiscGame({ name: 'Game', subGames: [discOne, discTwo] });
47+
const reWrapped = merged.withProps({ cloneOf: 'Parent' });
48+
expect(reWrapped).toBeInstanceOf(MergedDiscGame);
49+
expect(reWrapped.getSubGames()).toEqual([discOne, discTwo]);
50+
expect(reWrapped.getCloneOf()).toEqual('Parent');
51+
expect(reWrapped.getRoms()).toEqual([...discOne.getRoms(), ...discTwo.getRoms()]);
52+
});
53+
});

0 commit comments

Comments
 (0)