Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions electron/main/services/dmmMigration.guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,81 @@ describe('idempotency: a second run adopts nothing and mints no duplicates', ()
});
});

describe('unknown-mod rows from startup do not count as managed', () => {
const sha = (content: Buffer) => createHash('sha256').update(content).digest('hex');

it('adopts shared-folder files that only carry the backfilled hash and inferred hero', async () => {
const sb = makeSandbox();
const enabledBytes = Buffer.from('DMM-ENABLED-SKIN-111');
const disabledBytes = Buffer.from('DMM-DISABLED-HUD-222');
const liveSlot = join(sb.addons, 'pak50_dir.vpk');
const disabledSlot = join(sb.disabled, 'hud_thing_dir.vpk');
writeFileSync(liveSlot, enabledBytes);
writeFileSync(disabledSlot, disabledBytes);
// What backfillMissingMetadataHashes + enrichMod leave on unknown VPKs.
writeFileSync(
join(sb.userData, 'mod-metadata.json'),
JSON.stringify({
'pak50_dir.vpk': {
sha256: sha(enabledBytes),
globalType: null,
globalTypeClassifierVersion: 4,
lockerHero: 'Mina',
lockerHeroVpkChecked: true,
abilitySounds: null,
},
'hud_thing_dir.vpk': { sha256: sha(disabledBytes), globalType: 'hud', globalTypeClassifierVersion: 4 },
})
);
writeState(sb, [
{ id: 111, name: 'Enabled Skin', enabled: true, vpks: [liveSlot] },
{ id: 222, name: 'Hud Thing', enabled: false, vpks: [disabledSlot] },
]);

const addonsBefore = fileNames(sb.addons);
const report = await run(sb);
expect(report.adopted.map((a) => a.submissionId).sort()).toEqual([111, 222]);
const meta = JSON.parse(readFileSync(join(sb.userData, 'mod-metadata.json'), 'utf-8'));
expect(meta['pak50_dir.vpk'].gameBananaId).toBe(111);
expect(meta['hud_thing_dir.vpk'].gameBananaId).toBe(222);
// Adopted in place: no extra slot, no copy.
expect(fileNames(sb.addons)).toEqual(addonsBefore);
});

it('a hero the user assigned by hand still protects the file', async () => {
const sb = makeSandbox();
const bytes = Buffer.from('USERS-OWN-SKIN');
const liveSlot = join(sb.addons, 'pak50_dir.vpk');
writeFileSync(liveSlot, bytes);
writeFileSync(
join(sb.userData, 'mod-metadata.json'),
JSON.stringify({ 'pak50_dir.vpk': { sha256: sha(bytes), lockerHero: 'Mina', lockerHeroSource: 'manual' } })
);
writeState(sb, [{ id: 111, name: 'Enabled Skin', enabled: true, vpks: [liveSlot] }]);

const report = await run(sb);
expect(report.adopted).toEqual([]);
expect(report.skipped[0].reason).toContain('already managed');
});

it('identifies an unknown twin instead of copying the same bytes again', async () => {
const sb = makeSandbox();
const bytes = Buffer.from('PROFILE-COPY-333');
const profileVpk = join(sb.separate, 'pak01_dir.vpk');
const twin = join(sb.addons, 'pak50_dir.vpk');
writeFileSync(profileVpk, bytes);
writeFileSync(twin, bytes);
writeFileSync(join(sb.userData, 'mod-metadata.json'), JSON.stringify({ 'pak50_dir.vpk': { sha256: sha(bytes) } }));
writeState(sb, [{ id: 333, name: 'Profile Mod', enabled: true, vpks: [profileVpk] }]);

const addonsBefore = fileNames(sb.addons);
const report = await run(sb);
expect(report.adopted.map((a) => [a.submissionId, a.installedAs])).toEqual([[333, 'pak50_dir.vpk']]);
expect(fileNames(sb.addons)).toEqual(addonsBefore);
expect(existsSync(profileVpk)).toBe(true);
});
});

describe('staleness guard: stale DMM records cannot hijack reused slots', () => {
it('skips a bare pakNN slot that is newer than the record claiming it', async () => {
const sb = makeSandbox();
Expand Down
47 changes: 24 additions & 23 deletions electron/main/services/dmmMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import {
allocateEnabledVpkPath,
makeDisabledFileName,
runExclusiveModMutation,
scanMods,
} from './mods';
import {
setModMetadataWithHash,
getModMetadata,
removeModMetadata,
loadMetadata,
hasModIdentity,
hashFileSha256,
backupMetadataSidecar,
type ModMetadata,
Expand Down Expand Up @@ -247,24 +249,6 @@ function isLiveDisabledSlot(src: string, disabledPath: string): boolean {
return resolve(dirname(src)) === resolve(disabledPath);
}

/** Whether a metadata entry shows Grimoire already manages this VPK: a prior
* GameBanana install/import, a merged build, a Locker-managed surface, or a
* user-assigned hero. Adopting over such an entry in place would hijack a real
* mod's identity, so we skip it instead. */
function isGrimoireManaged(meta: ModMetadata): boolean {
return (
meta.gameBananaId !== undefined ||
meta.merged !== undefined ||
meta.lockerCosmetics !== undefined ||
meta.lockerSounds !== undefined ||
meta.lockerColors !== undefined ||
meta.lockerTrippySkins !== undefined ||
meta.soulImport !== undefined ||
meta.urnImport !== undefined ||
meta.lockerHero !== undefined
);
}

function metadataFor(entry: DmmAdoptionEntry): ModMetadata {
return {
modName: entry.modName,
Expand Down Expand Up @@ -356,9 +340,14 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
const allMetadata = loadMetadata();
const managedSubmissionIds = new Set<number>();
const managedHashes = new Set<string>();
for (const meta of Object.values(allMetadata)) {
// Unknown VPKs carry a hash too (startup backfills one for every file), but
// they are what this import exists to identify, so they are not "managed".
const unknownKeyByHash = new Map<string, string>();
for (const [metaKey, meta] of Object.entries(allMetadata)) {
if (meta.gameBananaId !== undefined) managedSubmissionIds.add(meta.gameBananaId);
if (meta.sha256) managedHashes.add(meta.sha256.toLowerCase());
if (!meta.sha256) continue;
if (hasModIdentity(meta)) managedHashes.add(meta.sha256.toLowerCase());
else unknownKeyByHash.set(meta.sha256.toLowerCase(), metaKey);
}

const adoptableEntries: DmmAdoptionEntry[] = [];
Expand Down Expand Up @@ -431,6 +420,9 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
? (await fs.readdir(disabledPath)).map((n) => n.toLowerCase())
: []
);
let pathByKey: Map<string, string> | null = null;
const installedPaths = async () =>
(pathByKey ??= new Map((await scanMods(opts.deadlockPath)).map((m) => [m.metaKey, m.path])));

for (const entry of ordered) {
// A DMM mod may own several VPKs; adopt each one, tagging all with the
Expand Down Expand Up @@ -474,7 +466,15 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
try {
let destPath: string;
await assertVpkSafety(src, { context: 'installation', name: entry.modName });
if (entry.enabled) {
// Grimoire may already list these exact bytes as an unknown mod in
// another file (DMM's live copy while this one sits in a profile
// folder, or the same mod dropped in by hand): identify that file
// rather than adding a second copy of the mod.
const twinKey = unknownKeyByHash.get(srcHash);
const twinPath = twinKey ? (await installedPaths()).get(twinKey) : undefined;
if (twinPath && resolve(twinPath) !== resolve(src)) {
destPath = twinPath;
} else if (entry.enabled) {
if (mode === 'in-place' && isLiveEnabledSlot(src, addonRoots)) {
// Already a live pakNN_dir.vpk slot Grimoire scans: adopt by
// metadata only, no copy.
Expand All @@ -483,7 +483,7 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
// Skip anything Grimoire already manages (a prior import, or a
// local/Locker VPK that happens to occupy this slot): re-tagging it
// would hijack its identity. Only a truly unmanaged file is adopted.
if (existing && isGrimoireManaged(existing)) {
if (existing && hasModIdentity(existing)) {
fileSkips.push(`${vpkName} (already managed by Grimoire)`);
continue;
}
Expand All @@ -503,7 +503,7 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
const existing = getModMetadata(metaKeyFor(destPath));
// Don't re-tag a file Grimoire already manages (a prior import or a
// Locker/local surface parked here): that would hijack its identity.
if (existing && isGrimoireManaged(existing)) {
if (existing && hasModIdentity(existing)) {
fileSkips.push(`${vpkName} (already managed by Grimoire)`);
continue;
}
Expand All @@ -528,6 +528,7 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise<DmmM
removeModMetadata(metaKey);
await setModMetadataWithHash(metaKey, meta, destPath);
managedHashes.add(srcHash);
unknownKeyByHash.delete(srcHash);
adoptedKeys.push(metaKey);
} catch (err) {
fileSkips.push(`${vpkName} (${err instanceof Error ? err.message : String(err)})`);
Expand Down
26 changes: 26 additions & 0 deletions electron/main/services/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,32 @@ export interface ModMetadata {

export type ModMetadataMap = Record<string, ModMetadata>;

/**
* Whether a sidecar row records an identity: a GameBanana link, a name, a
* Grimoire-built VPK, or a choice the user made about the file. Startup gives
* every unknown VPK a row too (the sha256 backfill, the inferred hero and
* global type), so the mere presence of a row, a hash or a `lockerHero` does
* not mean Grimoire manages the file.
*/
export function hasModIdentity(meta: ModMetadata): boolean {
return (
meta.gameBananaId !== undefined ||
(typeof meta.modName === 'string' && meta.modName.trim().length > 0) ||
meta.lockerHeroSource === 'manual' ||
meta.merged !== undefined ||
meta.forgeInstall !== undefined ||
meta.lockerCosmetics !== undefined ||
meta.lockerSounds !== undefined ||
meta.lockerColors !== undefined ||
meta.lockerTrippySkins !== undefined ||
meta.soulImport !== undefined ||
meta.urnImport !== undefined ||
meta.soundSwap !== undefined ||
meta.localGroupId !== undefined ||
meta.priorityMod !== undefined
);
}

// In-memory cache of the parsed metadata.json. Without this, every enrichMod
// call (one per installed mod) re-reads + re-parses the whole sidecar from
// disk on the main thread; users with many mods see noticeable freezes on
Expand Down
Loading