From a3649f1c4b7b479aa26fb8caf789a0aaae502069 Mon Sep 17 00:00:00 2001 From: slush97 Date: Mon, 5 Oct 2026 17:31:08 -0700 Subject: [PATCH] fix(dmm-import): stop treating unknown mods as already managed Since the startup sha256 backfill (#296), every unknown VPK gets a metadata row with a hash, and enrich adds an inferred lockerHero to hero skins. The DMM import counted any hashed row as managed and any lockerHero as owned, so on a shared addons folder it skipped nearly every DMM mod and never adopted a hero skin. Add hasModIdentity() in metadata.ts (GameBanana id, name, a Grimoire-built VPK or a user choice such as a manual hero) and use it for both checks. When DMM's file sits outside Grimoire's folders but Grimoire already lists the same bytes as an unknown mod, tag that file instead of copying a duplicate. --- .../main/services/dmmMigration.guards.test.ts | 75 +++++++++++++++++++ electron/main/services/dmmMigration.ts | 47 ++++++------ electron/main/services/metadata.ts | 26 +++++++ 3 files changed, 125 insertions(+), 23 deletions(-) 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