diff --git a/electron/main/services/dmmMigration.guards.test.ts b/electron/main/services/dmmMigration.guards.test.ts index c46870ef..14ac2617 100644 --- a/electron/main/services/dmmMigration.guards.test.ts +++ b/electron/main/services/dmmMigration.guards.test.ts @@ -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(); diff --git a/electron/main/services/dmmMigration.ts b/electron/main/services/dmmMigration.ts index 784ba371..691daf32 100644 --- a/electron/main/services/dmmMigration.ts +++ b/electron/main/services/dmmMigration.ts @@ -26,12 +26,14 @@ import { allocateEnabledVpkPath, makeDisabledFileName, runExclusiveModMutation, + scanMods, } from './mods'; import { setModMetadataWithHash, getModMetadata, removeModMetadata, loadMetadata, + hasModIdentity, hashFileSha256, backupMetadataSidecar, type ModMetadata, @@ -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, @@ -356,9 +340,14 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise(); const managedHashes = new Set(); - 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(); + 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[] = []; @@ -431,6 +420,9 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise n.toLowerCase()) : [] ); + let pathByKey: Map | 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 @@ -474,7 +466,15 @@ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise; +/** + * 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