diff --git a/AGENTS.md b/AGENTS.md index 5a2456fc..33f4e1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Electron multi-process with context isolation on and `nodeIntegration` off. **Adding an IPC method:** declare it once in the `ElectronAPI` interface in `src/types/electron.ts`, add the one-line bridge in `electron/preload/index.ts` (checked with `satisfies ElectronAPI`), then the handler in `electron/main/ipc/*`. **Adding a setting:** field in `AppSettings` (`src/types/mod.ts`) plus its default in `electron/main/services/settings.ts`. -Runtime data lives in the Electron `userData` dir: `mods-cache.db` (GameBanana catalog mirror + FTS5), `stats.db` (player stats), `unknown-crc-cache.db`, `settings.json`, `mod-metadata.json`, `profiles.json`, plus asset caches. +Runtime data lives in the Electron `userData` dir: `mods-cache.db` (GameBanana catalog mirror + FTS5), `stats.db` (player stats), `unknown-crc-cache.db`, `settings.json`, `mod-metadata.json`, `profiles.json`, `interchange-ledger.json`, plus asset caches. Heavy VPK/model/texture/sound work shells out to the bundled `vpkmerge` CLI (`resources/vpkmerge/`, version + sha256 pinned in `scripts/fetch-vpkmerge.mjs`). @@ -74,6 +74,7 @@ After any catalog change run `pnpm i18n:manifest`, or CI and pre-push fail. Tran | Area | Doc | |---|---| +| Manager-neutral mod transfer | `docs/mod-interchange.md` | | Portable profiles (`mp1:` codes, `.modprofile.json`) | `docs/profile-spec.md` | | Social layer, ADRs (append-only, never edit a shipped ADR) | `docs/social-architecture.md`, `docs/social-architecture-decisions.md` | | `gameinfo.gi` handling, Deadworks servers | `docs/deadworks-servers.md` | @@ -89,6 +90,7 @@ After any catalog change run `pnpm i18n:manifest`, or CI and pre-push fail. Tran ## Working conventions +- Mod transfer uses the interchange format. DMM input is converted through the reader in `electron/main/services/modInterchange/`, and new source managers are registered in `electron/main/ipc/modInterchange.ts`. - `Installed.tsx` and `Browse.tsx` are god files (thousands of lines). Put new features in their own components under `src/components//` rather than growing them. When splitting, make move-only PRs with no behavior or visual change mixed in. - Experimental features are gated behind `experimental*` settings (see `AppSettings`) and default off. - Commits: imperative, conventional-style subjects (`fix(installed): ...`, `feat: ...`). PRs are squash-merged. Branch protection has no required checks, so `gh pr merge --auto` merges immediately: check `gh pr checks` first. diff --git a/docs/mod-interchange.md b/docs/mod-interchange.md new file mode 100644 index 00000000..bf716f3f --- /dev/null +++ b/docs/mod-interchange.md @@ -0,0 +1,182 @@ +# Deadlock Mod Interchange format (v1) + +A manager-neutral description of an installed Deadlock mod library, used to move +mods between mod managers. Deadlock Mod Manager (DMM) and Grimoire both read and +write it; any other manager can join by implementing the same three adapters: + +| Adapter | Direction | +| -------- | --------------------------------------------------- | +| Reader | another manager's native on-disk data → interchange | +| Importer | interchange → your own layout | +| Exporter | your own layout → interchange bundle | + +A reader for a manager that does not speak the format yet (for example DMM reading +an older Grimoire, or Grimoire reading an older DMM) builds the same document in +memory straight from that manager's files. Importers never need to know where a +document came from. + +## Bundle layout + +``` +/ + mod-interchange.json + files//.vpk +``` + +A bundle may be passed around as a directory or as the path of its +`mod-interchange.json`. File paths inside the document are relative to the +directory that holds `mod-interchange.json`, use forward slashes, and must not +leave that directory. In-memory documents produced by a live reader may carry +absolute paths instead; they are never written to disk that way. + +## Document + +```jsonc +{ + "format": "deadlock-mod-interchange", + "version": 1, + "createdAt": "2026-09-19T12:00:00.000Z", + "source": { + "manager": "grimoire", // free-form id of the producing manager + "managerVersion": "1.26.0", // optional + "profileName": "Default", // optional + }, + "mods": [ + { + "key": "gamebanana:mod:650634", + "name": "QOL Lock", + "enabled": true, + "order": 0, + "origin": { + "provider": "gamebanana", + "submissionType": "mod", + "submissionId": "650634", + "fileId": 1720039, + "fileName": "qol_313.zip", + }, + "author": "someone", + "description": null, + "category": "Quality of Life/Fixes", + "hero": null, + "thumbnailUrl": "https://images.gamebanana.com/img/ss/mods/6995649268824.jpg", + "link": "https://gamebanana.com/mods/650634", + "nsfw": false, + "files": [ + { + "name": "qol_313_dir.vpk", + "path": "files/0000-qol-lock/qol_313_dir.vpk", + "sha256": "fcfe4bdeb53cf44dcdd5966e1a4538807ad17dccab80df76400f9397c114f6a5", + "size": 1667455, + "selected": true, + }, + ], + "extensions": { + "grimoire": { "metaKey": "pak02_dir.vpk" }, + }, + }, + ], + "warnings": [], +} +``` + +### Optional sections + +A document is modular: `mods` is always present, everything else only when +the producing manager has it and the user chose to export it. `contents` lists +the sections that were included, so an importer can tell "no profiles" from +"profiles not exported". + +```jsonc +{ + "contents": ["mods", "profiles", "crosshairs"], + "profiles": [ + { + "key": "profile:default", + "name": "Ranked", + "active": true, + "description": null, + "mods": [ + { "modKey": "gamebanana:mod:650634", "enabled": true, "order": 0 }, + ], + "crosshairKey": "crosshair:profile:default", + "autoexec": ["fps_max 240"], + }, + ], + "crosshairs": [ + { + "key": "crosshair:preset-1", + "name": "Small dot", + "active": false, + "convars": { + "citadel_crosshair_color_r": "255", + "citadel_crosshair_pip_gap": "4", + }, + }, + ], +} +``` + +- `mods` is the library: every mod any exported profile uses, once. Its + `enabled`/`order` describe the source's active profile, which is also the + whole state of a manager without profiles. +- `profiles[].mods[].modKey` points into `mods`. An importer that has no + profiles ignores the section. An importer that has profiles but receives a + document without them imports into its active profile. +- `crosshairs[].convars` are the game's own `citadel_crosshair_*` console + variables as strings, so no manager-specific crosshair model leaks in. + Importers keep the convars they understand and ignore the rest. +- `profiles[].autoexec` is a list of console commands saved with the profile. + +### Fields + +- `format` must be `deadlock-mod-interchange`. `version` is the major version; + readers reject a higher major they do not understand. Additive fields do not + bump the version, so readers ignore unknown fields. +- `key` is unique within a document and stable across exports of the same mod: + `gamebanana:mod:`, `gamebanana:sound:`, or `local:`. Local ids are + either the manager's own UUID or `sha256:` of the primary VPK, so + importing the same file twice resolves to the same mod. +- `order` is the load order within the document, ascending. Lower values take + the lower `pakNN` slot. Values do not need to be contiguous. +- `origin.provider` is `gamebanana` or `local`. For GameBanana, `submissionId` is + the decimal submission id as a string; `fileId` is the GameBanana file row + the VPKs came from, when known. +- `files` lists every VPK of the mod. `selected: false` marks an alternative + variant that is kept but not loaded; a missing `selected` means `true`. + `sha256` and `size` are optional but let importers deduplicate and verify. +- `extensions` holds manager-private data keyed by manager id. Importers must + not depend on another manager's extension. +- All other descriptive fields are optional and may be `null`. + +## Import rules + +Every importer in this repository follows these rules so a transfer always +finishes, even when the source data is incomplete or out of date: + +1. An entry whose files cannot be found is skipped with a reason. It never + aborts the rest of the import. +2. An entry already present in the target library (same GameBanana + submission, or the same local key) is skipped as a duplicate. +3. Missing names fall back to the source file name. Missing GameBanana + metadata is fetched from the catalog when available and otherwise kept from + the document. +4. The importer never deletes source files and never touches files outside + the folders it manages itself. Both managers share `citadel/addons`, so a + source file may already sit where the target manager lists its own mods: + such a file is adopted in place (and moved within those folders when the + document asks for another state, e.g. disabled), never copied, so the game + does not load a stray second copy. Everything else is copied. +5. Load order is preserved relative to the other imported entries and placed + after the mods already in the target profile. Readers take the order the + source manager shows, not incidental file or key order. +6. An importer remembers which document key became which mod in its library. + Re-importing after the user re-identified a mod (local to GameBanana) must + not bring the old copy back. Grimoire keeps this in + `interchange-ledger.json` in userData, per source manager: each key maps to + the content hashes of the VPKs it became (file names change when slots are + reordered), and each document profile to the Grimoire profile it created, + which a re-import refreshes instead of duplicating. +7. Profile entries that point at a mod which was skipped or failed are + dropped from that profile, never replaced by a guess. +8. Imported local mods are offered for identification afterwards (hash + lookup, manual GameBanana link). The step is optional. diff --git a/electron/main/index.ts b/electron/main/index.ts index 5d88c9f3..c5014358 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -99,8 +99,8 @@ import './ipc/saltIngest'; import './ipc/servers'; import './ipc/foundry'; import './ipc/performanceConfig'; -import './ipc/dmmMigrate'; import './ipc/forge'; +import './ipc/modInterchange'; import { initUpdater, checkForUpdates, getInstallSource } from './services/updater'; import { runStartupRecovery } from './ipc/launch'; diff --git a/electron/main/ipc/dmmMigrate.ts b/electron/main/ipc/dmmMigrate.ts deleted file mode 100644 index 977efdfc..00000000 --- a/electron/main/ipc/dmmMigrate.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { ipcMain } from 'electron'; -import { getActiveDeadlockPath } from '../services/settings'; -import { migrateDmmInstall } from '../services/dmmMigration'; -import { getModById, getModCount } from '../services/modDatabase'; -import { writeSnapshot } from '../services/snapshots'; -import type { DmmMigrationReport, DmmMigrationRequest } from '../../../src/lib/dmmMigration'; - -function requireDeadlockPath(): string { - const deadlockPath = getActiveDeadlockPath(); - if (!deadlockPath) { - throw new Error('No Deadlock path configured. Set it in Settings first.'); - } - return deadlockPath; -} - -// Submission-id validator backed by the local GameBanana catalog -// (mods-cache.db). Returns undefined when the catalog is empty or unavailable -// (fresh install, sync disabled): an empty catalog can't distinguish a wrong -// id from an unsynced one, so the migration then skips the check rather than -// rejecting everything. -function catalogSubmissionLookup(): ((id: number) => boolean) | undefined { - try { - if (getModCount() === 0) return undefined; - } catch { - return undefined; - } - return (id: number) => { - try { - return getModById(id) !== null; - } catch { - // A lookup failure must not reject a legitimate mod: fail open. - return true; - } - }; -} - -// Non-mutating preview: build the adoption plan and return what WOULD be -// adopted, without copying anything. Safe to run repeatedly against a real -// DMM install. Runs the same already-managed + catalog filters as execute, -// so the preview matches what execute will actually do. -ipcMain.handle('dmm-migrate:scan', (_, req: DmmMigrationRequest): Promise => { - return migrateDmmInstall({ - ...req, - deadlockPath: requireDeadlockPath(), - planOnly: true, - isKnownSubmission: catalogSubmissionLookup(), - }); -}); - -// Execute the migration: copy each VPK into Grimoire's layout and write its -// metadata. Non-destructive (DMM's files are left in place). A recovery -// snapshot is taken first, like the update path; its failure is non-fatal. -ipcMain.handle('dmm-migrate:execute', async (_, req: DmmMigrationRequest): Promise => { - const deadlockPath = requireDeadlockPath(); - try { - await writeSnapshot(deadlockPath, 'pre-dmm-import'); - } catch (err) { - console.warn('[DMM] pre-import snapshot failed (continuing):', err); - } - return migrateDmmInstall({ - ...req, - deadlockPath, - isKnownSubmission: catalogSubmissionLookup(), - }); -}); diff --git a/electron/main/ipc/modInterchange.ts b/electron/main/ipc/modInterchange.ts new file mode 100644 index 00000000..2bc2c87a --- /dev/null +++ b/electron/main/ipc/modInterchange.ts @@ -0,0 +1,145 @@ +import { ipcMain } from 'electron'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { getActiveDeadlockPath } from '../services/settings'; +import { isDeadlockRunning } from '../services/launch'; +import { getModById, getModCount } from '../services/modDatabase'; +import { writeSnapshot } from '../services/snapshots'; +import { detectDmm, readDmmDocument } from '../services/modInterchange/dmmReader'; +import { importInterchange } from '../services/modInterchange/importer'; +import { importInterchangeSelection } from '../services/modInterchange/selectionImport'; +import { exportGrimoireLibrary, readInterchangeBundle } from '../services/modInterchange/bundle'; +import type { + InterchangeDocument, + InterchangeExportReport, + InterchangeExportSelection, + InterchangeImportReport, + InterchangeImportSelection, + InterchangePreview, + InterchangeSourceInfo, + InterchangeSourceRequest, +} from '../../../src/lib/modInterchange'; + +function requireDeadlockPath(): string { + const deadlockPath = getActiveDeadlockPath(); + if (!deadlockPath) { + throw new Error('No Deadlock path configured. Set it in Settings first.'); + } + return deadlockPath; +} + +// Submission-id validator backed by the local GameBanana catalog +// (mods-cache.db). Returns undefined when the catalog is empty or unavailable +// (fresh install, sync disabled): an empty catalog can't distinguish a wrong +// id from an unsynced one, so the import then skips the check rather than +// rejecting everything. +function catalogSubmissionLookup(): ((id: number) => boolean) | undefined { + try { + if (getModCount() === 0) return undefined; + } catch { + return undefined; + } + return (id: number) => { + try { + return getModById(id) !== null; + } catch { + // A lookup failure must not reject a legitimate mod: fail open. + return true; + } + }; +} + +/** Mod managers Grimoire can read from disk. Supporting another one means a + * reader plus an entry here; the renderer lists whatever this returns. */ +const SOURCES: Array<{ + id: string; + detect: (deadlockPath: string | null) => Promise; + read: (deadlockPath: string, location?: string) => Promise; +}> = [ + { + id: 'deadlock-mod-manager', + detect: detectDmm, + // A picked folder is either DMM's data folder (holds state.json) or the + // folder with its mods (holds .dmm.json). + read: (deadlockPath, location) => + readDmmDocument({ + deadlockPath, + ...(location && existsSync(join(location, 'state.json')) + ? { dmmStatePath: join(location, 'state.json') } + : location + ? { dmmAddonsDir: location } + : {}), + }), + }, +]; + +async function readSource(req: InterchangeSourceRequest): Promise { + if (req.kind === 'bundle') return readInterchangeBundle(req.path); + const source = SOURCES.find((s) => s.id === req.id); + if (!source) throw new Error(`Unknown mod manager: ${req.id}`); + return source.read(requireDeadlockPath(), req.location); +} + +ipcMain.handle('interchange:sources', (): Promise => { + const deadlockPath = getActiveDeadlockPath(); + return Promise.all(SOURCES.map((s) => s.detect(deadlockPath))); +}); + +// Read a source into the neutral format and mark what Grimoire already has, +// using the same filters as the import so the preview never promises more. +ipcMain.handle( + 'interchange:read', + async (_, req: InterchangeSourceRequest): Promise => { + const document = await readSource(req); + const dryRun = await importInterchange(document, { + deadlockPath: requireDeadlockPath(), + planOnly: true, + isKnownSubmission: catalogSubmissionLookup(), + }); + return { document, status: dryRun.status }; + } +); + +// Import the chosen sections. A recovery snapshot is taken first, like the +// update path; its failure is non-fatal. Progress goes to the calling window. +ipcMain.handle( + 'interchange:import', + async ( + event, + req: { document: InterchangeDocument; selection: InterchangeImportSelection } + ): Promise => { + const deadlockPath = requireDeadlockPath(); + // The import moves and renames files in the addons folder the running + // game has open; Grimoire never touches loaded mods mid-session. + if (await isDeadlockRunning()) { + throw new Error('Close Deadlock before importing mods.'); + } + const fromDmm = req.document.source.manager === 'deadlock-mod-manager'; + try { + await writeSnapshot(deadlockPath, fromDmm ? 'pre-dmm-import' : 'pre-mod-import'); + } catch (err) { + console.warn('[Interchange] pre-import snapshot failed (continuing):', err); + } + return importInterchangeSelection(req.document, req.selection, { + deadlockPath, + isKnownSubmission: catalogSubmissionLookup(), + backupTag: fromDmm ? 'pre-dmm-import' : 'pre-mod-import', + onProgress: (progress) => { + if (!event.sender.isDestroyed()) event.sender.send('interchange:progress', progress); + }, + }); + } +); + +// Export Grimoire's library as a bundle other mod managers can import. +ipcMain.handle( + 'interchange:export', + ( + event, + req: { destinationDir: string; selection: InterchangeExportSelection } + ): Promise => { + return exportGrimoireLibrary(requireDeadlockPath(), req.destinationDir, req.selection, (progress) => { + if (!event.sender.isDestroyed()) event.sender.send('interchange:progress', progress); + }); + } +); diff --git a/electron/main/services/dmmMigration.ts b/electron/main/services/dmmMigration.ts index 691daf32..30d6f665 100644 --- a/electron/main/services/dmmMigration.ts +++ b/electron/main/services/dmmMigration.ts @@ -1,55 +1,29 @@ /** - * DMM -> Grimoire migration (Electron orchestration). Adopts a Deadlock Mod - * Manager install's on-disk VPKs into Grimoire's own management: copies each - * VPK into Grimoire's addons layout and writes the metadata sidecar so Grimoire - * recognizes the mod natively. No re-download, no DMM cloud. + * DMM -> Grimoire migration, expressed through the manager-neutral + * interchange format: the DMM reader (modInterchange/dmmReader.ts) turns DMM's + * on-disk data into an interchange document, and the interchange importer + * (modInterchange/importer.ts) adopts it. This wrapper keeps the migration's + * report shape for callers and tests that think in DMM terms. * - * The tiered decision logic is pure and unit tested in src/lib/dmmMigration.ts - * (composeDmmAdoptionPlan). This file only reads the two DMM files off disk, - * then for each planned mod calls Grimoire's existing install primitives: - * - allocateEnabledVpkPath (enabled mods: a pakNN slot, overflow-aware) - * - makeDisabledFileName (disabled mods: a free-form name in .disabled) - * - setModMetadataWithHash (write the sidecar keyed by metaKey + sha256) - * all wrapped in one runExclusiveModMutation batch so it's atomic vs UI toggles. - * - * Hero/global-type are intentionally left unset: Grimoire's enrichMod infers - * them from the adopted VPK file tree on the next scan ("auto recognize"). + * Nothing is deleted, but VPKs in the shared addons folder are adopted in + * place and moved or renamed into Grimoire's slots, so DMM's records for them + * go stale. See importer.ts for the in-place vs copy rules and the guards + * against stale DMM bookkeeping. */ -import { homedir } from 'os'; -import { assertVpkSafety } from './modSafety'; -import { join, basename, dirname, resolve, isAbsolute } from 'path'; -import { promises as fs, constants as fsConstants, existsSync } from 'fs'; - -import { getAddonsPath, getAddonFolderPaths, getDisabledPath, metaKeyFor } from './deadlock'; -import { - allocateEnabledVpkPath, - makeDisabledFileName, - runExclusiveModMutation, - scanMods, -} from './mods'; -import { - setModMetadataWithHash, - getModMetadata, - removeModMetadata, - loadMetadata, - hasModIdentity, - hashFileSha256, - backupMetadataSidecar, - type ModMetadata, -} from './metadata'; import { - composeDmmAdoptionPlan, - planToPreview, - submissionIdFromVpkName, + interchangeKeyForEntry, type DmmAdoptionEntry, - type DmmMigrationMode, type DmmMigrationReport, type DmmMigrationRequest, } from '../../../src/lib/dmmMigration'; +import { readDmmLibrary } from './modInterchange/dmmReader'; +import { importInterchange } from './modInterchange/importer'; -const DMM_TAURI_IDENTIFIER = 'dev.stormix.deadlock-mod-manager'; -const DMM_MANIFEST_FILENAME = '.dmm.json'; +export { + dmmStatePathCandidates, + defaultDmmStatePath, +} from './modInterchange/dmmReader'; export interface DmmMigrationOptions extends DmmMigrationRequest { /** Grimoire's Deadlock path (the migration target). Resolved from settings @@ -61,507 +35,59 @@ export interface DmmMigrationOptions extends DmmMigrationRequest { * (mods-cache.db). Injected by the IPC layer so this service stays free of * native sqlite imports (its tests run without Electron). Omitted = catalog * unavailable, check skipped. Guards against stamping a bogus id (e.g. a - * digit-prefixed filename that isn't a GameBanana id) onto a real file: - * such ids render as arbitrary submissions from unrelated games. */ + * digit-prefixed filename that isn't a GameBanana id) onto a real file. */ isKnownSubmission?: (submissionId: number) => boolean; } -/** Tolerance when comparing a VPK's mtime against the mtime of the DMM record - * that claims it (filesystems and DMM's deploy-then-save ordering can put - * them within the same second). */ -const STALE_CLAIM_SLACK_MS = 2000; - -/** Collapse a name to lowercase alphanumerics for fuzzy comparison. */ -function normalizeForMatch(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); -} - -/** Whether the on-disk filename resembles the DMM record's own name for the - * mod (display name or source archive stem). Rescues legitimate adoptions - * whose mtime corroboration fails because the files were copied to a new - * drive or machine (copies refresh mtimes). */ -function nameCorroborates(fileName: string, entry: DmmAdoptionEntry): boolean { - let stem = fileName.replace(/_dir\.vpk$/i, '').replace(/\.vpk$/i, ''); - stem = stem.replace(/^pak\d{2}_?/i, ''); - const normalizedStem = normalizeForMatch(stem); - if (normalizedStem.length < 5) return false; - for (const candidate of [entry.modName, entry.sourceFileName]) { - if (!candidate) continue; - const normalized = normalizeForMatch(candidate); - if (normalized.length < 5) continue; - if (normalizedStem.includes(normalized) || normalized.includes(normalizedStem)) return true; - } - return false; -} - -/** - * Staleness guard. DMM's records claim shared pakNN/.disabled slots by bare - * path, and Grimoire reuses those exact slots, so a record written long ago - * can now point at a completely different mod. Stamping DMM's identity onto - * such a file hijacks it (wrong name/thumbnail/GameBanana id on a real mod). - * A claim is trusted only when the file is provably the one DMM recorded: - * - the filename carries the submission-id prefix DMM mints (`_*.vpk`), or - * - the file is not newer than the DMM record that claims it (a slot Grimoire - * reused was rewritten AFTER DMM last saved its bookkeeping), or - * - the filename matches the mod's recorded name. - */ -async function claimCorroborated( - src: string, - entry: DmmAdoptionEntry, - claimMtimeMs: number -): Promise { - const name = basename(src); - if (name.toLowerCase().startsWith(`${entry.submissionId}_`)) return true; - if (claimMtimeMs > 0) { - try { - const stat = await fs.stat(src); - if (stat.mtimeMs <= claimMtimeMs + STALE_CLAIM_SLACK_MS) return true; - } catch { - return false; - } - } - return nameCorroborates(name, entry); -} - -/** Candidate on-disk locations of DMM's state.json. Tauri's store-plugin base - * dir varies by version (appDataDir vs appConfigDir), so we probe both. In the - * wild (Linux) it lands in the XDG DATA dir (~/.local/share), not config. */ -export function dmmStatePathCandidates(): string[] { - const home = homedir(); - const id = DMM_TAURI_IDENTIFIER; - if (process.platform === 'win32') { - const roaming = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); - const local = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'); - return [join(roaming, id, 'state.json'), join(local, id, 'state.json')]; - } - if (process.platform === 'darwin') { - return [join(home, 'Library', 'Application Support', id, 'state.json')]; - } - const data = process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'); - const config = process.env.XDG_CONFIG_HOME ?? join(home, '.config'); - return [join(data, id, 'state.json'), join(config, id, 'state.json')]; -} - -/** First state.json candidate that exists, else the first candidate (so error - * messages still name a concrete path). */ -export function defaultDmmStatePath(): string { - const candidates = dmmStatePathCandidates(); - return candidates.find((p) => existsSync(p)) ?? candidates[0]; -} - -/** Read a file's text, or null if it doesn't exist / can't be read. */ -async function readTextOrNull(path: string): Promise { - try { - return await fs.readFile(path, 'utf-8'); - } catch { - return null; - } -} - -/** Locate DMM's `.dmm.json`, checking the given dir and one level of subfolders - * (DMM writes it into a profile subfolder, e.g. `addons/profile_default/`). - * Returns the parsed text + the folder that holds it (where its VPKs live). */ -async function findDmmManifest( - dmmAddonsDir: string -): Promise<{ json: string; dir: string; path: string } | null> { - const top = join(dmmAddonsDir, DMM_MANIFEST_FILENAME); - const topJson = await readTextOrNull(top); - if (topJson !== null) return { json: topJson, dir: dmmAddonsDir, path: top }; - - const subdirs = await fs.readdir(dmmAddonsDir, { withFileTypes: true }).catch(() => []); - for (const dirent of subdirs) { - if (!dirent.isDirectory()) continue; - const dir = join(dmmAddonsDir, dirent.name); - const path = join(dir, DMM_MANIFEST_FILENAME); - const json = await readTextOrNull(path); - if (json !== null) return { json, dir, path }; - } - return null; -} - -/** Resolve a mod's VPK to an on-disk path. Candidates may be absolute paths - * (state.json records full paths in installedVpks) or basenames (.dmm.json). - * Absolute paths are used directly; basenames are looked up in the DMM addons - * dir and one level of subfolders (DMM profile subfolders). */ -async function locateVpk(dmmAddonsDir: string, candidates: string[]): Promise { - for (const name of candidates) { - if (isAbsolute(name)) { - if (existsSync(name)) return name; - continue; - } - const direct = join(dmmAddonsDir, name); - if (existsSync(direct)) return direct; - } - const relative = candidates.filter((n) => !isAbsolute(n)); - if (relative.length === 0) return null; - const subdirs = await fs.readdir(dmmAddonsDir, { withFileTypes: true }).catch(() => []); - for (const dirent of subdirs) { - if (!dirent.isDirectory()) continue; - for (const name of relative) { - const nested = join(dmmAddonsDir, dirent.name, name); - if (existsSync(nested)) return nested; - } - } - return null; -} - -/** Scan Grimoire's addon folders (+ .disabled) for DMM-named `_*.vpk` - * files, grouped by submission id. Feeds the planner's fallback for mods whose - * on-disk filename DMM's data didn't record. */ -async function scanIdPrefixedVpks(dirs: string[]): Promise> { - const map = new Map(); - for (const dir of dirs) { - const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); - for (const dirent of entries) { - if (!dirent.isFile()) continue; - const id = submissionIdFromVpkName(dirent.name); - if (id === null) continue; - const full = join(dir, dirent.name); - const arr = map.get(id); - if (arr) arr.push(full); - else map.set(id, [full]); - } - } - return map; -} - -/** Whether `src` is already a live, engine-loadable enabled slot: a `*_dir.vpk` - * sitting directly in one of Grimoire's addon roots (NOT `.disabled`, NOT a - * parked `_name.vpk`). Only such a file can be adopted in place. Anything - * else (a parked name, a file the fallback found in `.disabled`) must be - * promoted into a real pakNN slot, or the mod would be reported enabled yet - * stay invisible to scanMods (which requires `_dir.vpk`) and unloaded by the - * game. */ -function isLiveEnabledSlot(src: string, addonRoots: string[]): boolean { - if (!basename(src).toLowerCase().endsWith('_dir.vpk')) return false; - const parent = resolve(dirname(src)); - return addonRoots.some((root) => resolve(root) === parent); -} - -/** Whether `src` is already a valid disabled slot Grimoire scans: a `*_dir.vpk` - * sitting directly in Grimoire's `.disabled` folder. DMM (at least the current - * Linux build) shares this exact folder and deploys its disabled mods into it - * with the same `*_dir.vpk` naming, so such a file is already a fully-formed - * Grimoire disabled slot. It is adopted by metadata only, with no move, so - * DMM's recorded absolute path stays valid and nothing on disk shifts. */ -function isLiveDisabledSlot(src: string, disabledPath: string): boolean { - if (!basename(src).toLowerCase().endsWith('_dir.vpk')) return false; - return resolve(dirname(src)) === resolve(disabledPath); -} - -function metadataFor(entry: DmmAdoptionEntry): ModMetadata { - return { - modName: entry.modName, - gameBananaId: entry.submissionId, - gameBananaFileId: entry.fileId, - categoryName: entry.categoryName, - thumbnailUrl: entry.thumbnailUrl, - sourceFileName: entry.sourceFileName, - sourceSection: 'Mod', - // Stash the DMM load-order slot so a later enable (for disabled adoptions) - // can try to restore the position. Harmless on enabled adoptions. - lastPriority: entry.priority, - // Deliberately no lockerHero/globalType: enrichMod infers them from the VPK. - }; -} - -/** - * Migrate (or, with planOnly, preview) a DMM install. Non-destructive: DMM's - * files are never moved or deleted, so its install keeps working after import. - * Adoption is decided per file by where it already lives: - * - in place (metadata only, no file op) when the VPK is already a `*_dir.vpk` - * in a folder Grimoire scans: enabled mods in citadel/addons, disabled mods - * in citadel/addons/.disabled (DMM shares both). This is the common case. - * - copy when DMM's file is outside those folders (a separate profile subfolder - * or a copy): a duplicate is brought into Grimoire's layout, leaving DMM's - * original untouched. - * Returns a report; throws only when no DMM data is found at all. - */ +/** Migrate (or, with planOnly, preview) a DMM install. Throws only when no DMM + * data is found at all. */ export async function migrateDmmInstall(opts: DmmMigrationOptions): Promise { - const grimoireAddons = getAddonsPath(opts.deadlockPath); - // Default to the shared addons folder (DMM's default drop location), so the - // common case needs no folder pick and runs in-place. - const searchDir = opts.dmmAddonsDir ?? grimoireAddons; - - // DMM may keep `.dmm.json` in the addons root OR in a profile subfolder; find - // it and use its containing folder as the VPK source. - const manifestHit = await findDmmManifest(searchDir); - const dmmAddonsDir = manifestHit?.dir ?? searchDir; - const statePath = opts.dmmStatePath ?? defaultDmmStatePath(); - const stateJson = await readTextOrNull(statePath); - - // In-place only when the folder DMM's VPKs actually live in IS Grimoire's - // addons root (a profile subfolder is a separate dir -> copy). - const mode: DmmMigrationMode = - resolve(dmmAddonsDir) === resolve(grimoireAddons) ? 'in-place' : 'copy'; - - // Discover DMM's actively-loaded `_*.vpk` files so mods whose path DMM - // didn't record can still be adopted by their filename id prefix. - const addonRoots = getAddonFolderPaths(opts.deadlockPath); - const extraVpkBySubmission = await scanIdPrefixedVpks([ - ...addonRoots, - getDisabledPath(opts.deadlockPath), - ]); - - let composed; - try { - composed = composeDmmAdoptionPlan(manifestHit?.json ?? null, stateJson, { - profileId: opts.profileId, - profileName: opts.profileName, - extraVpkBySubmission, - }); - } catch { - // No usable DMM data: turn the bare "No DMM data" into something the user - // can act on, naming exactly where we looked. - throw new Error( - `No Deadlock Mod Manager data found.\n` + - `Looked for a .dmm.json in: ${searchDir} (and its subfolders) -> ${manifestHit ? 'found' : 'not found'}.\n` + - `Looked for DMM's state.json at: ${statePath} -> ${stateJson ? 'found' : 'not found'}.\n` + - `If your DMM mods are elsewhere, click Browse and pick the folder that contains them ` + - `(or DMM's profile subfolder).` - ); - } - const { plan, enrichment } = composed; - - const report: DmmMigrationReport = { - profileName: plan.profileName, - enrichment, - mode, - preview: [], - adopted: [], - skipped: [], - warnings: [...plan.warnings], - }; - - // Idempotency + validity filters run BEFORE the preview is built, so scan - // and execute agree on the same entry set and a re-run (another Fix Unknown - // click) is a true no-op for everything adopted last time, instead of - // minting duplicate slots/copies of it. - const allMetadata = loadMetadata(); - const managedSubmissionIds = new Set(); - const managedHashes = new Set(); - // 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) continue; - if (hasModIdentity(meta)) managedHashes.add(meta.sha256.toLowerCase()); - else unknownKeyByHash.set(meta.sha256.toLowerCase(), metaKey); - } - - const adoptableEntries: DmmAdoptionEntry[] = []; - let unknownToCatalog = 0; - for (const entry of plan.entries) { - if (managedSubmissionIds.has(entry.submissionId)) { - report.skipped.push({ - submissionId: entry.submissionId, - reason: 'already managed by Grimoire (submission id present in metadata)', - }); - } else if (opts.isKnownSubmission && !opts.isKnownSubmission(entry.submissionId)) { - unknownToCatalog++; - report.skipped.push({ - submissionId: entry.submissionId, - reason: - 'submission id not found in the local GameBanana catalog (wrong id or catalog out of date); left for manual identification', - }); - } else { - adoptableEntries.push(entry); - } - } - if (unknownToCatalog > 0) { - report.warnings.push( - `${unknownToCatalog} mod(s) skipped: their DMM submission id is not in the local GameBanana catalog.` - ); - } - report.preview = planToPreview({ ...plan, entries: adoptableEntries }); - - if (opts.planOnly) return report; - - // Staleness reference: a file claim is only as fresh as the DMM bookkeeping - // file that made it (the manifest when present, else state.json). - let claimMtimeMs = 0; - try { - const claimFile = manifestHit ? manifestHit.path : statePath; - claimMtimeMs = (await fs.stat(claimFile)).mtimeMs; - } catch { - claimMtimeMs = 0; - } - - // Recoverability: back up the metadata sidecar before the first identity - // write (lazily, inside the batch), so a bad import can be rolled back by - // restoring one file. (The IPC layer additionally takes a mod snapshot.) - // Lazy on purpose: an execute whose every file is skipped (e.g. a - // perpetually stale entry re-clicked repeatedly) writes nothing and must - // not mint a backup, or it would rotate the REAL pre-import backup out of - // the pruned set. Non-fatal on failure. - let sidecarBackedUp = false; - const backupSidecarOnce = () => { - if (sidecarBackedUp) return; - sidecarBackedUp = true; - const backupPath = backupMetadataSidecar('pre-dmm-import'); - if (backupPath) console.log(`[DMM] metadata sidecar backed up to ${backupPath}`); - }; - - // Adopt enabled mods first (ascending priority so sequential slot allocation - // -> pak01, pak02, ... preserves load order in copy mode; harmless in-place), - // then disabled mods. - const ordered = [...adoptableEntries].sort((a, b) => { - if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; - return a.priority - b.priority; + const read = await readDmmLibrary(opts); + const outcome = await importInterchange(read.document, { + deadlockPath: opts.deadlockPath, + planOnly: opts.planOnly, + isKnownSubmission: opts.isKnownSubmission, + backupTag: 'pre-dmm-import', }); - const disabledPath = getDisabledPath(opts.deadlockPath); - - await runExclusiveModMutation(async () => { - // Seed the .disabled taken-set once; we add to it as we mint names. - const disabledTaken = new Set( - existsSync(disabledPath) - ? (await fs.readdir(disabledPath)).map((n) => 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 - // same submission id so the Installed page groups them into one card. - const meta = metadataFor(entry); - const adoptedKeys: string[] = []; - const fileSkips: string[] = []; - - for (const vpkName of entry.vpkFiles) { - const src = await locateVpk(dmmAddonsDir, [vpkName]); - if (!src) { - fileSkips.push(`${vpkName} (not found on disk)`); - continue; - } - - // Staleness guard: only adopt a file the DMM record provably still - // describes; a shared slot Grimoire has since reused must stay - // untouched (see claimCorroborated). - if (!(await claimCorroborated(src, entry, claimMtimeMs))) { - fileSkips.push( - `${vpkName} (file is newer than the DMM record claiming it; possibly a reused slot, left for manual identification)` - ); - continue; - } - - // Content dedupe: if a byte-identical file is already managed (e.g. a - // copy minted by an earlier import run whose metadata lost the id), - // adopting another copy only duplicates the mod. - let srcHash: string; - try { - srcHash = (await hashFileSha256(src)).toLowerCase(); - } catch (err) { - fileSkips.push(`${vpkName} (${err instanceof Error ? err.message : String(err)})`); - continue; - } - if (managedHashes.has(srcHash)) { - fileSkips.push(`${vpkName} (an identical file is already managed by Grimoire)`); - continue; - } - - try { - let destPath: string; - await assertVpkSafety(src, { context: 'installation', name: entry.modName }); - // 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. - destPath = src; - const existing = getModMetadata(metaKeyFor(destPath)); - // 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 && hasModIdentity(existing)) { - fileSkips.push(`${vpkName} (already managed by Grimoire)`); - continue; - } - } else { - // Not a live slot (copy mode, a parked `_name.vpk`, or a file the - // fallback found in .disabled): promote into a real pakNN slot so it - // actually loads. Must write before the next allocation so the slot - // scan sees it taken. - destPath = await allocateEnabledVpkPath(opts.deadlockPath); - await fs.copyFile(src, destPath, fsConstants.COPYFILE_EXCL); - } - } else if (isLiveDisabledSlot(src, disabledPath)) { - // Already a valid disabled slot in Grimoire's .disabled folder (DMM - // shares it): adopt by metadata only, no move. Non-destructive, so - // DMM's recorded path keeps working and the file never shifts. - destPath = src; - 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 && hasModIdentity(existing)) { - fileSkips.push(`${vpkName} (already managed by Grimoire)`); - continue; - } - } else { - // DMM's file lives outside Grimoire's scanned folders (a separate - // profile subfolder or copy): bring a COPY into .disabled under a - // free-form name. Always copy, never move, so DMM stays intact. - const nameHint = entry.modName ?? entry.sourceFileName ?? basename(src); - const disabledName = makeDisabledFileName(basename(src), disabledTaken, nameHint); - disabledTaken.add(disabledName.toLowerCase()); - if (!existsSync(disabledPath)) await fs.mkdir(disabledPath, { recursive: true }); - destPath = join(disabledPath, disabledName); - await fs.copyFile(src, destPath, fsConstants.COPYFILE_EXCL); - } - - const metaKey = metaKeyFor(destPath); - // Clear any orphaned sidecar entry at this key first: an allocated slot - // is only guaranteed free on disk, so a stale entry from a deleted mod - // could otherwise bleed its fields (lockerHero, merged, thumbnail) into - // this one via setModMetadata's shallow merge. - backupSidecarOnce(); - 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)})`); - } - } - - if (adoptedKeys.length > 0) { - report.adopted.push({ - submissionId: entry.submissionId, - fileId: entry.fileId, - modName: entry.modName, - installedAs: adoptedKeys[0], - enabled: entry.enabled, - priority: entry.priority, - }); - } else { - report.skipped.push({ - submissionId: entry.submissionId, - reason: fileSkips.length > 0 ? fileSkips.join('; ') : 'no VPK files to adopt', - }); - } - } - }); - - // Diagnostic summary (main-process log): the renderer only surfaces a count, so - // this is where a "only N imported" report can be traced to its real cause. - console.log( - `[DMM] migrate: adopted ${report.adopted.length} mod(s), skipped ${report.skipped.length} ` + - `(mode=${report.mode}, enrichment=${report.enrichment})` + const planned = new Map( + read.plan.entries.map((entry) => [interchangeKeyForEntry(entry), entry]) ); - for (const s of report.skipped) { - console.log(`[DMM] skipped ${s.submissionId}: ${s.reason}`); - } + const submissionIdOf = (key: string) => planned.get(key)?.submissionId ?? 0; - return report; + return { + profileName: read.plan.profileName, + enrichment: read.enrichment, + mode: read.mode, + preview: outcome.adoptable.map((mod) => ({ + key: mod.key, + submissionId: submissionIdOf(mod.key), + modName: planned.get(mod.key)?.modName, + enabled: mod.enabled, + priority: mod.order, + hasFileId: planned.get(mod.key)?.fileId !== undefined, + })), + adopted: outcome.results + .filter((result) => result.status === 'imported') + .map((result) => { + const entry = planned.get(result.key); + return { + key: result.key, + submissionId: submissionIdOf(result.key), + fileId: entry?.fileId, + modName: entry?.modName, + installedAs: result.installedAs ?? '', + enabled: entry?.enabled ?? false, + priority: entry?.priority ?? 0, + }; + }), + skipped: outcome.results + .filter((result) => result.status !== 'imported') + .map((result) => ({ + key: result.key, + submissionId: submissionIdOf(result.key), + reason: result.reason ?? 'not imported', + })), + warnings: [...read.document.warnings, ...outcome.warnings], + }; } diff --git a/electron/main/services/modInterchange/bundle.ts b/electron/main/services/modInterchange/bundle.ts new file mode 100644 index 00000000..618e4cb4 --- /dev/null +++ b/electron/main/services/modInterchange/bundle.ts @@ -0,0 +1,349 @@ +/** + * Interchange bundles on disk: reading one produced by any mod manager, and + * exporting Grimoire's own library as one (Grimoire -> interchange exporter). + * + * A bundle is a folder holding `mod-interchange.json` plus the VPKs it lists, + * with paths relative to the manifest (docs/mod-interchange.md). + */ + +import { app } from 'electron'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; +import { existsSync, promises as fs, readFileSync, statSync } from 'fs'; + +import { scanMods } from '../mods'; +import { loadProfiles } from '../profiles'; +import { loadSettings } from '../settings'; +import { getUserDataPath } from '../../utils/paths'; +import { crosshairConvarsFromSettings } from '../../../../src/lib/crosshair'; +import { hashFileSha256, loadMetadata, type ModMetadata } from '../metadata'; +import { resolveVpkIdentity } from '../vpkIdentity'; +import { + INTERCHANGE_MANIFEST, + dropDanglingProfileEntries, + gameBananaKey, + includeSection, + isPlainVpkName, + newInterchangeDocument, + parseInterchangeDocument, + type InterchangeDocument, + type InterchangeExportReport, + type InterchangeExportSelection, + type InterchangeFile, + type InterchangeMod, + type InterchangeProgress, +} from '../../../../src/lib/modInterchange'; + +export const GRIMOIRE_MANAGER_ID = 'grimoire'; + +/** Accepts the bundle folder or the manifest path itself. */ +export function manifestPathFor(path: string): string { + try { + if (statSync(path).isDirectory()) return join(path, INTERCHANGE_MANIFEST); + } catch { + // Not there: let the read below report it. + } + return path; +} + +/** Read a bundle and rewrite its file paths to absolute ones. Paths that are + * absolute or leave the bundle folder are dropped with a warning, so a + * crafted manifest can never make the importer read files elsewhere. */ +export async function readInterchangeBundle(path: string): Promise { + const manifestPath = manifestPathFor(path); + let text: string; + try { + text = await fs.readFile(manifestPath, 'utf-8'); + } catch (err) { + throw new Error( + `Could not read ${manifestPath}: ${err instanceof Error ? err.message : String(err)}` + ); + } + const document = parseInterchangeDocument(text); + const base = resolve(dirname(manifestPath)); + for (const mod of document.mods) { + mod.files = mod.files.filter((file) => { + const target = resolve(base, file.path); + const rel = relative(base, target); + if (isAbsolute(file.path) || rel.startsWith('..') || isAbsolute(rel)) { + document.warnings.push(`${mod.name}: ignored file path outside the bundle (${file.path})`); + return false; + } + file.path = target; + return true; + }); + } + return document; +} + +const GENERATED_FLAGS: Array = [ + 'lockerCosmetics', + 'lockerSounds', + 'lockerColors', + 'lockerTrippySkins', +]; + +function slugify(value: string, max = 40): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, max) + .replace(/_+$/, ''); +} + +function folderIndex(path: string): number { + const match = basename(dirname(path)).match(/^addons(\d+)$/i); + return match ? parseInt(match[1], 10) : 0; +} + +interface ExportItem { + path: string; + metaKey: string; + fileName: string; + enabled: boolean; + loadPosition: number; + meta?: ModMetadata; + sha256: string; + size: number; +} + +/** Export Grimoire's library (enabled and disabled mods) as a bundle inside + * `destinationDir`. Variants of one GameBanana submission stay together as + * one entry; Locker-generated VPKs are left out. */ +export async function exportGrimoireLibrary( + deadlockPath: string, + destinationDir: string, + selection: InterchangeExportSelection = { profiles: true, crosshairs: true }, + onProgress?: (progress: InterchangeProgress) => void +): Promise { + if (!existsSync(destinationDir) || !statSync(destinationDir).isDirectory()) { + throw new Error(`Export folder does not exist: ${destinationDir}`); + } + const metadata = loadMetadata(); + const mods = await scanMods(deadlockPath); + const skipped: InterchangeExportReport['skipped'] = []; + + const items: ExportItem[] = []; + let stale = 0; + for (const mod of mods) { + let meta: ModMetadata | undefined = metadata[mod.metaKey]; + if (meta && GENERATED_FLAGS.some((flag) => meta?.[flag] !== undefined)) continue; + try { + // A sidecar entry whose fingerprint no longer matches the file belongs + // to whatever used this slot before (another tool reused it). Export + // the bytes as a local mod rather than under that stale identity. + if (meta?.sha256) { + const identity = await resolveVpkIdentity(mod.path); + if (identity.sha256.toLowerCase() !== meta.sha256.toLowerCase()) { + meta = undefined; + stale++; + } + } + items.push({ + path: mod.path, + metaKey: mod.metaKey, + fileName: mod.fileName, + enabled: mod.enabled, + loadPosition: mod.enabled + ? folderIndex(mod.path) * 100 + mod.priority + : 100_000 + (meta?.lastPriority ?? mod.priority), + meta, + sha256: (await hashFileSha256(mod.path)).toLowerCase(), + size: mod.size, + }); + } catch (err) { + skipped.push({ + name: meta?.modName ?? mod.fileName, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + const warnings: string[] = []; + if (stale > 0) { + warnings.push( + `${stale} file(s) no longer match Grimoire's recorded fingerprint; exported as local mods` + ); + } + + // Group into interchange entries: one per GameBanana submission, one per + // distinct local file. Every file name / metaKey maps to its entry so + // profiles (which store file names) can reference entries. + const groups = new Map(); + const keyByFile = new Map(); + for (const item of items) { + const gbId = item.meta?.gameBananaId; + const key = gbId + ? gameBananaKey(item.meta?.sourceSection === 'Sound' ? 'sound' : 'mod', gbId) + : `local:sha256:${item.sha256}`; + const list = groups.get(key); + if (list) list.push(item); + else groups.set(key, [item]); + keyByFile.set(item.metaKey.toLowerCase(), key); + keyByFile.set(item.fileName.toLowerCase(), key); + } + + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + let bundle = join(destinationDir, `grimoire-mods-export-${stamp}`); + for (let n = 2; existsSync(bundle); n++) { + bundle = join(destinationDir, `grimoire-mods-export-${stamp}-${n}`); + } + await fs.mkdir(join(bundle, 'files'), { recursive: true }); + + const document = newInterchangeDocument({ + manager: GRIMOIRE_MANAGER_ID, + managerVersion: app.getVersion(), + }); + document.warnings.push(...warnings); + + const entries = [...groups.entries()] + .map(([key, list]) => { + list.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.loadPosition - b.loadPosition); + const seen = new Set(); + return { key, list: list.filter((item) => !seen.has(item.sha256) && seen.add(item.sha256)) }; + }) + .sort((a, b) => a.list[0].loadPosition - b.list[0].loadPosition); + + for (const [index, { key, list }] of entries.entries()) { + onProgress?.({ stage: 'mods', current: index, total: entries.length, name: key }); + const meta = list.find((item) => item.meta)?.meta; + const first = list[0]; + const name = + meta?.modName?.trim() || + meta?.sourceFileName || + first.fileName.replace(/_dir\.vpk$/i, '').replace(/\.vpk$/i, ''); + const enabled = list.some((item) => item.enabled); + const slug = slugify(name) || 'mod'; + const folder = `${String(index).padStart(4, '0')}-${slug}`; + + const usedNames = new Set(); + const files: InterchangeFile[] = []; + for (const [fileIndex, item] of list.entries()) { + let fileName = + /^pak\d{2,3}_dir\.vpk$/i.test(item.fileName) || !isPlainVpkName(item.fileName) + ? `${slug}_dir.vpk` + : item.fileName; + for (let n = 2; usedNames.has(fileName.toLowerCase()); n++) fileName = `${slug}_${n}_dir.vpk`; + usedNames.add(fileName.toLowerCase()); + const relativePath = ['files', folder, fileName].join('/'); + await fs.mkdir(join(bundle, 'files', folder), { recursive: true }); + await fs.copyFile(item.path, join(bundle, ...relativePath.split('/'))); + files.push({ + name: fileName, + path: relativePath, + sha256: item.sha256, + size: item.size, + selected: enabled ? item.enabled : fileIndex === 0, + }); + } + + const gbId = meta?.gameBananaId; + const isSound = meta?.sourceSection === 'Sound'; + const mod: InterchangeMod = { + key, + name, + enabled, + order: index, + origin: gbId + ? { + provider: 'gamebanana', + submissionType: isSound ? 'sound' : 'mod', + submissionId: String(gbId), + fileId: meta?.gameBananaFileId, + fileName: meta?.sourceFileName, + } + : { provider: 'local' }, + author: meta?.author ?? null, + description: null, + category: meta?.categoryName ?? null, + hero: meta?.lockerHero ?? null, + thumbnailUrl: meta?.thumbnailUrl ?? null, + link: gbId ? `https://gamebanana.com/${isSound ? 'sounds' : 'mods'}/${gbId}` : null, + nsfw: meta?.nsfw ?? null, + files, + extensions: { [GRIMOIRE_MANAGER_ID]: { metaKeys: list.map((item) => item.metaKey) } }, + }; + document.mods.push(mod); + } + + onProgress?.({ stage: 'mods', current: entries.length, total: entries.length, name: '' }); + + if (selection.profiles) exportProfiles(document, keyByFile); + if (selection.crosshairs) exportCrosshairPresets(document); + dropDanglingProfileEntries(document); + + await fs.writeFile(join(bundle, INTERCHANGE_MANIFEST), JSON.stringify(document, null, 2)); + return { + bundlePath: bundle, + exported: document.mods.length, + profiles: document.profiles.length, + crosshairs: document.crosshairs.length, + skipped, + }; +} + +/** Grimoire profiles reference installed files by GameBanana id (stable) or + * file name; both resolve to the exported entry keys. */ +function exportProfiles(document: InterchangeDocument, keyByFile: Map): void { + const exported = new Set(document.mods.map((m) => m.key)); + const activeId = loadSettings().activeProfileId; + for (const profile of loadProfiles()) { + const mods: InterchangeDocument['profiles'][number]['mods'] = []; + for (const [index, entry] of profile.mods.entries()) { + const byId = + entry.gameBananaId !== undefined + ? [gameBananaKey('mod', entry.gameBananaId), gameBananaKey('sound', entry.gameBananaId)].find( + (key) => exported.has(key) + ) + : undefined; + const modKey = byId ?? keyByFile.get(entry.fileName.toLowerCase()); + if (!modKey || mods.some((m) => m.modKey === modKey)) continue; + mods.push({ modKey, enabled: entry.enabled, order: entry.priority ?? index }); + } + let crosshairKey: string | null = null; + if (profile.crosshair) { + crosshairKey = `crosshair:profile:${profile.id}`; + document.crosshairs.push({ + key: crosshairKey, + name: `${profile.name} crosshair`, + active: false, + convars: crosshairConvarsFromSettings(profile.crosshair), + }); + includeSection(document, 'crosshairs'); + } + document.profiles.push({ + key: `profile:${profile.id}`, + name: profile.name, + active: profile.id === activeId, + description: null, + mods: mods.sort((a, b) => a.order - b.order), + crosshairKey, + autoexec: profile.autoexecCommands?.length ? profile.autoexecCommands : null, + }); + } + if (document.profiles.length > 0) includeSection(document, 'profiles'); +} + +function exportCrosshairPresets(document: InterchangeDocument): void { + const path = join(getUserDataPath(), 'crosshair-presets.json'); + if (!existsSync(path)) return; + try { + const file = JSON.parse(readFileSync(path, 'utf-8')) as { + presets?: Array<{ id: string; name: string; settings: Record }>; + activePresetId?: string | null; + }; + for (const preset of file.presets ?? []) { + document.crosshairs.push({ + key: `crosshair:preset:${preset.id}`, + name: preset.name, + active: preset.id === file.activePresetId, + convars: crosshairConvarsFromSettings(preset.settings), + }); + } + if (document.crosshairs.length > 0) includeSection(document, 'crosshairs'); + } catch (err) { + document.warnings.push( + `Crosshair presets could not be read: ${err instanceof Error ? err.message : String(err)}` + ); + } +} diff --git a/electron/main/services/modInterchange/dmmReader.ts b/electron/main/services/modInterchange/dmmReader.ts new file mode 100644 index 00000000..2945dee1 --- /dev/null +++ b/electron/main/services/modInterchange/dmmReader.ts @@ -0,0 +1,672 @@ +/** + * Reader adapter: Deadlock Mod Manager's native on-disk data -> interchange + * document. Works against every DMM version seen in the wild: + * + * - `.dmm.json` v1 (no shards) through v3 (enabled VPKs spread over + * `citadel/addons/` shards), in the addons root or a profile + * subfolder, + * - `state.json` in either Tauri store location, with `local-config` stored + * as a string or an object, + * - mod ids `123`, `snd-123` and `local-`. + * + * Fallbacks, in order, per mod: the manifest's recorded files; the other list + * (enabled vs disabled) when DMM left the expected one empty; `_*.vpk` + * files found on disk; DMM's own download cache (`/mods//files`) + * when the addon files are gone entirely. Nothing is written. + */ + +import { homedir } from 'os'; +import { basename, dirname, isAbsolute, join, resolve } from 'path'; +import { existsSync, promises as fs } from 'fs'; + +import { getAddonFolderPaths, getAddonsPath, getDisabledPath } from '../deadlock'; +import { hashFileSha256 } from '../metadata'; +import { + composeDmmAdoptionPlan, + dmmIdFromVpkName, + interchangeKeyForEntry, + submissionIdFromVpkName, + type DmmAdoptionEntry, + type DmmAdoptionPlan, + type DmmEnrichment, + type DmmMigrationMode, + type DmmMigrationRequest, +} from '../../../../src/lib/dmmMigration'; +import { + parseDmmState, + selectDmmProfile, + unwrapDmmStateEnvelope, +} from '../../../../src/lib/dmmState'; +import { + includeSection, + newInterchangeDocument, + type InterchangeDocument, + type InterchangeMod, + type InterchangeSourceInfo, +} from '../../../../src/lib/modInterchange'; + +export const DMM_TAURI_IDENTIFIER = 'dev.stormix.deadlock-mod-manager'; +export const DMM_MANAGER_ID = 'deadlock-mod-manager'; +const DMM_MANIFEST_FILENAME = '.dmm.json'; + +/** Per-mod data the importer needs to apply DMM-specific guards. */ +export interface DmmModExtension { + dmmId: string; + shard: number; + /** mtime of the DMM bookkeeping that claims this mod's files. */ + claimMtimeMs: number; + /** Files taken from DMM's id-keyed download cache (always trustworthy). */ + storeFiles: string[]; +} + +/** Candidate on-disk locations of DMM's state.json. Tauri's store-plugin base + * dir varies by version (appDataDir vs appConfigDir), so we probe both. In the + * wild (Linux) it lands in the XDG DATA dir (~/.local/share), not config. */ +export function dmmStatePathCandidates(): string[] { + return dmmDataDirCandidates().map((dir) => join(dir, 'state.json')); +} + +/** Folders DMM may use for its app data, most likely first. */ +export function dmmDataDirCandidates(): string[] { + const home = homedir(); + const id = DMM_TAURI_IDENTIFIER; + if (process.platform === 'win32') { + const roaming = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); + const local = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'); + return [join(roaming, id), join(local, id)]; + } + if (process.platform === 'darwin') { + return [join(home, 'Library', 'Application Support', id)]; + } + const data = process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'); + const config = process.env.XDG_CONFIG_HOME ?? join(home, '.config'); + const flatpak = join(home, '.var', 'app', id); + return [ + join(data, id), + join(config, id), + join(flatpak, 'data', id), + join(flatpak, 'config', id), + ]; +} + +/** First state.json candidate that exists, else the first candidate (so error + * messages still name a concrete path). */ +export function defaultDmmStatePath(): string { + const candidates = dmmStatePathCandidates(); + return candidates.find((p) => existsSync(p)) ?? candidates[0]; +} + +/** DMM's download cache (`app_local_data_dir/mods`), which holds every + * downloaded VPK under `/files`. Next to state.json on most systems, + * under LOCALAPPDATA on Windows. */ +function dmmModStoreCandidates(statePath: string): string[] { + const dirs = [dirname(statePath), ...dmmDataDirCandidates()].map((dir) => join(dir, 'mods')); + return [...new Set(dirs.map((dir) => resolve(dir)))]; +} + +async function readTextOrNull(path: string): Promise { + try { + return await fs.readFile(path, 'utf-8'); + } catch { + return null; + } +} + +async function mtimeOf(path: string): Promise { + try { + return (await fs.stat(path)).mtimeMs; + } catch { + return 0; + } +} + +interface ManifestHit { + json: string; + dir: string; + path: string; +} + +async function manifestIn(dir: string): Promise { + const path = join(dir, DMM_MANIFEST_FILENAME); + const json = await readTextOrNull(path); + return json === null ? null : { json, dir, path }; +} + +/** Locate DMM's `.dmm.json`: the chosen profile's folder first, then the given + * dir, then one level of subfolders (DMM profile folders). */ +async function findDmmManifest( + searchDir: string, + preferredFolder: string | null | undefined +): Promise { + if (preferredFolder) { + const hit = await manifestIn(join(searchDir, preferredFolder)); + if (hit) return hit; + } + const top = await manifestIn(searchDir); + if (top) return top; + const subdirs = await fs.readdir(searchDir, { withFileTypes: true }).catch(() => []); + for (const dirent of subdirs) { + if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue; + const hit = await manifestIn(join(searchDir, dirent.name)); + if (hit) return hit; + } + return null; +} + +/** The directory holding shard `shard` of a profile whose base is `baseDir`: + * shard 1 is the base itself, shard N is `citadel/addons[/]`. */ +export function dmmShardDir(baseDir: string, shard: number): string { + if (shard <= 1) return baseDir; + const parts: string[] = []; + let cursor = resolve(baseDir); + while (basename(cursor).toLowerCase() !== 'addons') { + const parent = dirname(cursor); + if (parent === cursor) return baseDir; + parts.unshift(basename(cursor)); + cursor = parent; + } + return join(dirname(cursor), `addons${shard}`, ...parts); +} + +/** Scan folders for DMM-parked `_*.vpk` files, grouped by DMM id and + * by numeric submission id (the latter for the planner's legacy option). */ +async function scanIdPrefixedVpks( + dirs: string[] +): Promise<{ byDmmId: Map; bySubmission: Map }> { + const byDmmId = new Map(); + const bySubmission = new Map(); + const push = (map: Map, key: K, value: string) => { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); + }; + for (const dir of dirs) { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const dirent of entries) { + if (!dirent.isFile()) continue; + const full = join(dir, dirent.name); + const dmmId = dmmIdFromVpkName(dirent.name); + if (dmmId) push(byDmmId, dmmId, full); + const submissionId = submissionIdFromVpkName(dirent.name); + if (submissionId !== null) push(bySubmission, submissionId, full); + } + } + return { byDmmId, bySubmission }; +} + +async function vpksUnder(dir: string): Promise { + const found: string[] = []; + const pending = [dir]; + while (pending.length > 0) { + const current = pending.pop()!; + const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []); + for (const dirent of entries) { + const full = join(current, dirent.name); + if (dirent.isDirectory()) pending.push(full); + else if (dirent.isFile() && /\.vpk$/i.test(dirent.name)) found.push(full); + } + } + return found.sort(); +} + +const PAK_SLOT_RE = /^pak\d+_dir\.vpk$/i; + +/** Resolve one recorded file name for a planned entry. A `pakNN_dir.vpk` slot + * name only means something in the folder DMM recorded it for: every folder + * (shard, profile) has its own pak01, so it is never looked up elsewhere. */ +async function locateFile( + name: string, + entry: DmmAdoptionEntry, + baseDir: string +): Promise { + if (isAbsolute(name)) return existsSync(name) ? name : null; + if (PAK_SLOT_RE.test(name)) { + const slot = join(entry.enabled ? dmmShardDir(baseDir, entry.shard) : baseDir, name); + return existsSync(slot) ? slot : null; + } + const candidates = [ + entry.enabled ? join(dmmShardDir(baseDir, entry.shard), name) : null, + join(baseDir, name), + ].filter((p): p is string => p !== null); + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + // A parked `_.vpk` is unique to its mod, so it may be found in + // a nested folder; anything else could belong to another profile. + if (dmmIdFromVpkName(name) !== entry.dmmId) return null; + const subdirs = await fs.readdir(baseDir, { withFileTypes: true }).catch(() => []); + for (const dirent of subdirs) { + if (!dirent.isDirectory()) continue; + const nested = join(baseDir, dirent.name, name); + if (existsSync(nested)) return nested; + } + return null; +} + +async function resolveFromStore( + entry: DmmAdoptionEntry, + storeDirs: string[] +): Promise { + for (const store of storeDirs) { + const files = await vpksUnder(join(store, entry.dmmId, 'files')); + if (files.length === 0) continue; + const wanted = new Set( + [...(entry.selectedVpkNames ?? []), ...entry.vpkFiles] + .filter((n) => !isAbsolute(n)) + .map((n) => n.toLowerCase()) + ); + const picked = files.filter((f) => wanted.has(basename(f).toLowerCase())); + return picked.length > 0 ? picked : files; + } + return []; +} + +export interface DmmReadOptions extends DmmMigrationRequest { + deadlockPath: string; + /** Read exactly this folder's `.dmm.json` (a known profile's own folder) + * instead of searching for one. */ + manifestDir?: string; +} + +export interface DmmReadResult { + document: InterchangeDocument; + plan: DmmAdoptionPlan; + enrichment: DmmEnrichment; + mode: DmmMigrationMode; +} + +/** Is DMM installed? Found when its state.json or a `.dmm.json` exists. */ +export async function detectDmm(deadlockPath: string | null): Promise { + const statePath = defaultDmmStatePath(); + const stateJson = await readTextOrNull(statePath); + const searched = [...dmmStatePathCandidates()]; + let manifestPath: string | null = null; + if (deadlockPath) { + const addons = getAddonsPath(deadlockPath); + searched.push(join(addons, DMM_MANIFEST_FILENAME)); + manifestPath = (await findDmmManifest(addons, null))?.path ?? null; + } + return { + id: DMM_MANAGER_ID, + name: 'Deadlock Mod Manager', + found: stateJson !== null || manifestPath !== null, + location: stateJson !== null ? dirname(statePath) : manifestPath ? dirname(manifestPath) : null, + sections: ['mods', 'profiles', 'crosshairs'], + searched, + }; +} + +/** Read one DMM profile as an interchange document with absolute paths. */ +export async function readDmmLibrary(opts: DmmReadOptions): Promise { + const grimoireAddons = getAddonsPath(opts.deadlockPath); + const searchDir = opts.dmmAddonsDir ?? grimoireAddons; + const statePath = opts.dmmStatePath ?? defaultDmmStatePath(); + const stateJson = await readTextOrNull(statePath); + + let preferredFolder: string | null = null; + if (stateJson) { + try { + preferredFolder = selectDmmProfile(parseDmmState(stateJson), opts.profileId)?.folderName ?? null; + } catch { + preferredFolder = null; + } + } + // A known profile reads only its own folder: falling back to another + // folder's manifest would hand this profile someone else's mods. + const manifestHit = opts.manifestDir + ? await manifestIn(opts.manifestDir) + : await findDmmManifest(searchDir, preferredFolder); + const baseDir = + opts.manifestDir ?? + manifestHit?.dir ?? + (preferredFolder ? join(searchDir, preferredFolder) : searchDir); + + const mode: DmmMigrationMode = + resolve(baseDir) === resolve(grimoireAddons) ? 'in-place' : 'copy'; + + const scanDirs = [ + ...getAddonFolderPaths(opts.deadlockPath), + getDisabledPath(opts.deadlockPath), + ...(resolve(baseDir) === resolve(grimoireAddons) ? [] : [baseDir]), + ]; + const extra = await scanIdPrefixedVpks(scanDirs); + + let composed; + try { + composed = composeDmmAdoptionPlan(manifestHit?.json ?? null, stateJson, { + profileId: opts.profileId, + profileName: opts.profileName, + extraVpkBySubmission: extra.bySubmission, + extraVpkByDmmId: extra.byDmmId, + }); + } catch { + throw new Error( + `No Deadlock Mod Manager data found.\n` + + `Looked for a .dmm.json in: ${searchDir} (and its subfolders) -> ${manifestHit ? 'found' : 'not found'}.\n` + + `Looked for DMM's state.json at: ${statePath} -> ${stateJson ? 'found' : 'not found'}.\n` + + `If your DMM mods are elsewhere, click Browse and pick the folder that contains them ` + + `(or DMM's profile subfolder).` + ); + } + const { plan, enrichment } = composed; + const claimMtimeMs = await mtimeOf(manifestHit ? manifestHit.path : statePath); + const storeDirs = dmmModStoreCandidates(statePath); + + const document = newInterchangeDocument({ + manager: DMM_MANAGER_ID, + profileName: plan.profileName, + }); + document.warnings.push(...plan.warnings); + + let restoredFromStore = 0; + for (const entry of plan.entries) { + const files: string[] = []; + const missing: string[] = []; + for (const name of entry.storeOnly ? [] : entry.vpkFiles) { + const found = await locateFile(name, entry, baseDir); + if (found) files.push(found); + else missing.push(name); + } + let storeFiles: string[] = []; + if (files.length === 0) { + storeFiles = await resolveFromStore(entry, storeDirs); + if (storeFiles.length > 0) restoredFromStore++; + } + const allFiles = [...files, ...storeFiles]; + if (allFiles.length === 0) { + document.warnings.push( + `Skipped ${entry.modName ?? entry.dmmId}: its VPK file(s) are gone (${(entry.storeOnly ? entry.vpkFiles : missing).join(', ')})` + ); + continue; + } + document.mods.push(entryToInterchange(entry, allFiles, { claimMtimeMs, storeFiles })); + } + // Object key order puts numeric DMM ids first; the document follows load order. + document.mods.sort((a, b) => a.order - b.order); + if (restoredFromStore > 0) { + document.warnings.push( + `${restoredFromStore} mod(s) were missing from the addons folder and come from DMM's download cache` + ); + } + + return { document, plan, enrichment, mode }; +} + +function entryToInterchange( + entry: DmmAdoptionEntry, + files: string[], + guard: { claimMtimeMs: number; storeFiles: string[] } +): InterchangeMod { + const isLocal = entry.kind === 'local'; + const section = entry.kind === 'sound' ? 'sounds' : 'mods'; + const extension: DmmModExtension = { + dmmId: entry.dmmId, + shard: entry.shard, + claimMtimeMs: guard.claimMtimeMs, + storeFiles: guard.storeFiles, + }; + return { + key: interchangeKeyForEntry(entry), + name: + entry.modName?.trim() || + entry.sourceFileName || + (isLocal ? 'DMM local mod' : `GameBanana ${entry.kind} ${entry.submissionId}`), + enabled: entry.enabled, + order: entry.priority, + origin: isLocal + ? { provider: 'local', localId: entry.localId } + : { + provider: 'gamebanana', + submissionType: entry.kind === 'sound' ? 'sound' : 'mod', + submissionId: String(entry.submissionId), + fileId: entry.fileId, + fileName: entry.sourceFileName, + }, + author: entry.author ?? null, + description: entry.description ?? null, + category: entry.categoryName ?? null, + hero: null, + thumbnailUrl: entry.thumbnailUrl ?? null, + link: isLocal ? null : `https://gamebanana.com/${section}/${entry.submissionId}`, + nsfw: null, + files: files.map((path) => ({ name: basename(path), path, selected: true })), + extensions: { [DMM_MANAGER_ID]: { ...extension } }, + }; +} + +/** The DMM guard data an importer should honor for one entry, if any. */ +export function dmmExtensionOf(mod: InterchangeMod): DmmModExtension | null { + const ext = mod.extensions?.[DMM_MANAGER_ID]; + if (!ext || typeof ext.claimMtimeMs !== 'number' || typeof ext.dmmId !== 'string') return null; + return { + dmmId: ext.dmmId, + shard: typeof ext.shard === 'number' ? ext.shard : 1, + claimMtimeMs: ext.claimMtimeMs, + storeFiles: Array.isArray(ext.storeFiles) + ? ext.storeFiles.filter((p): p is string => typeof p === 'string') + : [], + }; +} + +/** DMM's crosshair model -> game convars. */ +function dmmCrosshairConvars(raw: unknown): Record | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; + const c = raw as Record; + const color = (typeof c.color === 'object' && c.color !== null ? c.color : {}) as Record< + string, + unknown + >; + const convars: Record = {}; + const put = (convar: string, value: unknown) => { + if (typeof value === 'number' && Number.isFinite(value)) convars[convar] = String(value); + else if (typeof value === 'boolean') convars[convar] = String(value); + }; + put('citadel_crosshair_pip_gap', c.gap); + put('citadel_crosshair_pip_width', c.width); + put('citadel_crosshair_pip_height', c.height); + put('citadel_crosshair_pip_opacity', c.pipOpacity); + put('citadel_crosshair_dot_opacity', c.dotOpacity); + put('citadel_crosshair_dot_outline_opacity', c.dotOutlineOpacity); + put('citadel_crosshair_color_r', color.r); + put('citadel_crosshair_color_g', color.g); + put('citadel_crosshair_color_b', color.b); + put('citadel_crosshair_pip_border', c.pipBorder); + put('citadel_crosshair_pip_gap_static', c.pipGapStatic); + return Object.keys(convars).length > 0 ? convars : null; +} + +function readDmmCrosshairs(stateJson: string | null, document: InterchangeDocument): void { + if (!stateJson) return; + let state: Record; + try { + state = unwrapDmmStateEnvelope(stateJson); + } catch { + return; + } + const seen = new Set(); + const add = (raw: unknown, name: string, active: boolean) => { + const convars = dmmCrosshairConvars(raw); + if (!convars) return; + const fingerprint = JSON.stringify(convars); + if (seen.has(fingerprint)) return; + seen.add(fingerprint); + document.crosshairs.push({ key: `crosshair:dmm:${seen.size}`, name, active, convars }); + }; + add(state.activeCrosshair, 'DMM active crosshair', true); + const history = Array.isArray(state.activeCrosshairHistory) ? state.activeCrosshairHistory : []; + history.forEach((raw, index) => add(raw, `DMM crosshair ${index + 1}`, false)); + if (document.crosshairs.length > 0) includeSection(document, 'crosshairs'); +} + +/** DMM names a profile folder `_` with a + * `profile__` id. Recover a readable name from it. */ +export function dmmProfileNameFromFolder(folder: string): string { + const name = folder.replace(/^profile_\d+_[a-z0-9]+_/i, '').replace(/[-_]+/g, ' ').trim(); + return name || folder; +} + +interface DmmProfileSource { + id: string; + name: string; + manifestDir: string; +} + +/** Without state.json (DMM uninstalled with its data, or a picked folder) the + * profiles are still on disk: the folder itself, then every subfolder that + * holds a `.dmm.json`. */ +async function discoverDmmProfiles(searchDir: string): Promise { + const found: DmmProfileSource[] = []; + if (existsSync(join(searchDir, DMM_MANIFEST_FILENAME))) { + found.push({ id: 'default', name: 'Default Profile', manifestDir: searchDir }); + } + const subdirs = await fs.readdir(searchDir, { withFileTypes: true }).catch(() => []); + for (const dirent of subdirs) { + if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue; + const dir = join(searchDir, dirent.name); + if (!existsSync(join(dir, DMM_MANIFEST_FILENAME))) continue; + found.push({ id: `folder:${dirent.name}`, name: dmmProfileNameFromFolder(dirent.name), manifestDir: dir }); + } + return found; +} + +/** + * Read DMM completely: every profile (each is its own addons folder), the + * union of their mods as the library, and the crosshairs. The library's + * enabled/order is the active profile's state; mods only other profiles use + * come after it, disabled. A profile that cannot be read costs only itself. + * + * DMM's default profile lives in the folders Grimoire scans, so its files are + * Grimoire mods the moment Grimoire looks. Every such file is therefore part + * of the library (as the mod's source, or as a kept variant), so the import + * consumes it instead of leaving a stray copy for the game to load. + */ +export async function readDmmDocument(opts: DmmReadOptions): Promise { + const statePath = opts.dmmStatePath ?? defaultDmmStatePath(); + const stateJson = await readTextOrNull(statePath); + const addons = getAddonsPath(opts.deadlockPath); + let sources: DmmProfileSource[] = []; + let activeId: string | undefined; + let fromState = false; + if (stateJson && !opts.dmmAddonsDir) { + try { + const state = parseDmmState(stateJson); + sources = state.profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + manifestDir: profile.folderName ? join(addons, profile.folderName) : addons, + })); + activeId = selectDmmProfile(state, opts.profileId)?.id; + fromState = sources.length > 0; + } catch { + sources = []; + } + } + if (sources.length === 0) { + sources = await discoverDmmProfiles(opts.dmmAddonsDir ?? addons); + activeId = sources[0]?.id; + } + + if (sources.length === 0) { + // Nothing on disk but maybe a state.json: one unnamed profile. + const single = await readDmmLibrary(opts); + readDmmCrosshairs(stateJson, single.document); + return single.document; + } + + const ordered = [...sources].sort( + (a, b) => Number(b.id === activeId) - Number(a.id === activeId) + ); + const document = newInterchangeDocument({ + manager: DMM_MANAGER_ID, + profileName: ordered[0].name, + }); + const scanned = [...getAddonFolderPaths(opts.deadlockPath), getDisabledPath(opts.deadlockPath)].map( + (dir) => resolve(dir).toLowerCase() + ); + const inGrimoireScan = (path: string) => scanned.includes(resolve(dirname(path)).toLowerCase()); + const library = new Map(); + const warnings = new Set(); + for (const source of ordered) { + const isActive = source.id === activeId; + let read: DmmReadResult; + try { + read = await readDmmLibrary({ + ...opts, + profileId: fromState ? source.id : undefined, + profileName: source.name, + manifestDir: source.manifestDir, + }); + } catch (err) { + document.warnings.push( + `Profile ${source.name} could not be read: ${ + err instanceof Error ? err.message.split('\n')[0] : String(err) + }` + ); + continue; + } + for (const w of read.document.warnings) warnings.add(isActive ? w : `${source.name}: ${w}`); + for (const mod of read.document.mods) { + const existing = library.get(mod.key); + if (!existing) { + library.set(mod.key, { ...mod, enabled: isActive ? mod.enabled : false, files: [...mod.files] }); + document.mods.push(library.get(mod.key)!); + continue; + } + await mergeScannedFiles(existing, mod, inGrimoireScan); + } + document.profiles.push({ + key: `profile:${source.id}`, + name: source.name, + active: isActive, + description: null, + mods: read.document.mods.map((mod) => ({ + modKey: mod.key, + enabled: mod.enabled, + order: mod.order, + })), + crosshairKey: null, + autoexec: null, + }); + } + document.mods.forEach((mod, index) => { + mod.order = index; + }); + document.warnings.push(...warnings); + includeSection(document, 'profiles'); + readDmmCrosshairs(stateJson, document); + return document; +} + +/** Another profile's copy of a library mod. When that copy sits where + * Grimoire scans, it must be consumed by the import: an identical copy + * becomes the source (so it is adopted, not left behind), a different one is + * kept as an unselected variant of the same mod. */ +async function mergeScannedFiles( + library: InterchangeMod, + other: InterchangeMod, + inGrimoireScan: (path: string) => boolean +): Promise { + const known = new Set(library.files.map((f) => resolve(f.path).toLowerCase())); + for (const file of other.files) { + if (!inGrimoireScan(file.path) || known.has(resolve(file.path).toLowerCase())) continue; + const hash = await hashFileSha256(file.path).catch(() => null); + if (!hash) continue; + let replaced = false; + for (const current of library.files) { + if (inGrimoireScan(current.path)) continue; + const currentHash = await hashFileSha256(current.path).catch(() => null); + if (currentHash && currentHash.toLowerCase() === hash.toLowerCase()) { + current.path = file.path; + replaced = true; + break; + } + } + if (!replaced) { + const taken = new Set(library.files.map((f) => f.name.toLowerCase())); + let name = file.name; + for (let n = 2; taken.has(name.toLowerCase()); n++) name = file.name.replace(/(_dir)?\.vpk$/i, `_${n}$1.vpk`); + library.files.push({ ...file, name, selected: false }); + } + known.add(resolve(file.path).toLowerCase()); + } +} diff --git a/electron/main/services/modInterchange/importer.ts b/electron/main/services/modInterchange/importer.ts new file mode 100644 index 00000000..d4b5fde4 --- /dev/null +++ b/electron/main/services/modInterchange/importer.ts @@ -0,0 +1,649 @@ +/** + * Importer: interchange document -> Grimoire. + * + * Every entry becomes one or more VPKs in Grimoire's own layout with a + * metadata sidecar entry, exactly like a GameBanana download or a custom + * import would leave it: + * - in place (metadata only, no file op) when the VPK already sits in a slot + * Grimoire scans: a `pakNN_dir.vpk` in an addon root for enabled mods, a + * `*_dir.vpk` in `.disabled` for disabled ones. That is the shared-folder + * case with DMM's default profile. + * - moved when the VPK sits in an addon root Grimoire scans but not in the + * state the document asks for (a DMM-parked `_*.vpk`, or an enabled + * slot of a DMM profile that is not the active one): left there it would + * show up as a second, enabled mod the game keeps loading. + * - copied otherwise: into the next free pakNN slot (enabled) or `.disabled` + * (disabled). Files outside Grimoire's folders are never moved or deleted. + * Enabled imports are then laid out after Grimoire's existing mods, in + * document order. + * Unselected variants (`selected: false`) are kept as disabled VPKs of the + * same mod, so Grimoire's variant switching can pick them later. + * + * Guards carried over from the DMM migration incident (see + * dmmMigration.guards.test.ts): already-managed submissions are skipped, + * submission ids the local catalog does not know are never stamped, byte- + * identical files are not duplicated, Grimoire-managed slots are never re- + * tagged, DMM claims older than the file they point at are ignored, and the + * sidecar is backed up lazily before the first write. + * + * Re-runs: the import ledger (ledger.ts) remembers which installed files each + * document key became. A key found there resolves to those files whatever the + * source's records now point at, and a file another key became is never + * attributed to this one. + */ + +import { basename, dirname, join, resolve } from 'path'; +import { promises as fs, constants as fsConstants, existsSync } from 'fs'; + +import { getAddonFolderPaths, getDisabledPath, isPriorityFolderPath, metaKeyFor } from '../deadlock'; +import { assertVpkSafety } from '../modSafety'; +import { + allocateEnabledVpkPath, + generateModId, + makeDisabledFileName, + reorderModsUnlocked, + runExclusiveModMutation, + scanMods, +} from '../mods'; +import { + backupMetadataSidecar, + getModMetadata, + hasModIdentity, + hashFileSha256, + loadMetadata, + removeModMetadata, + setModMetadataWithHash, + type ModMetadata, +} from '../metadata'; +import type { + InterchangeDocument, + InterchangeEntryStatus, + InterchangeImportReport, + InterchangeImportResult, + InterchangeMod, +} from '../../../../src/lib/modInterchange'; +import { dmmIdFromVpkName } from '../../../../src/lib/dmmMigration'; +import { dmmExtensionOf } from './dmmReader'; +import { ledgerModsFor, recordLedgerMods, type LedgerModRecord } from './ledger'; + +/** Tolerance when comparing a VPK's mtime against the mtime of the DMM record + * that claims it (filesystems and DMM's deploy-then-save ordering can put + * them within the same second). */ +const STALE_CLAIM_SLACK_MS = 2000; + +export interface InterchangeImportOptions { + deadlockPath: string; + /** Import only these keys; all when omitted. */ + keys?: string[]; + /** Dry run: decide what would happen, write nothing. */ + planOnly?: boolean; + /** Validates a GameBanana submission id against the local catalog. Omitted + * = catalog unavailable, check skipped. */ + isKnownSubmission?: (submissionId: number) => boolean; + /** Tag for the metadata sidecar backup. */ + backupTag?: string; + /** Called before each mod is processed, and once more when done. */ + onProgress?: (current: number, total: number, name: string) => void; +} + +export interface InterchangeImportOutcome + extends Pick { + /** Entries that pass the identity filters, i.e. what the import will try. */ + adoptable: InterchangeMod[]; + /** Entry key -> filter decision, for previews. */ + status: Record; +} + +const gameBananaIdOf = (mod: InterchangeMod): number | undefined => + mod.origin.provider === 'gamebanana' ? Number(mod.origin.submissionId) : undefined; + +function stripArchiveExt(name: string): string { + return name.replace(/\.(zip|7z|rar|tar|gz|vpk)$/i, ''); +} + +function metadataFor(mod: InterchangeMod): ModMetadata { + const gb = mod.origin.provider === 'gamebanana' ? mod.origin : null; + const thumbnail = mod.thumbnailUrl && /^https?:\/\//i.test(mod.thumbnailUrl) ? mod.thumbnailUrl : undefined; + return { + modName: mod.name, + author: mod.author ?? undefined, + gameBananaId: gb ? Number(gb.submissionId) : undefined, + gameBananaFileId: gb?.fileId, + categoryName: mod.category ?? undefined, + thumbnailUrl: thumbnail, + sourceFileName: gb?.fileName ? stripArchiveExt(gb.fileName) : undefined, + sourceSection: gb ? (gb.submissionType === 'sound' ? 'Sound' : 'Mod') : undefined, + nsfw: mod.nsfw ?? undefined, + // Stash the source load-order slot so a later enable (for disabled + // adoptions) can try to restore the position. Harmless on enabled ones. + lastPriority: mod.order, + // Deliberately no lockerHero/globalType: enrichMod infers them from the VPK. + }; +} + +/** Collapse a name to lowercase alphanumerics for fuzzy comparison. */ +function normalizeForMatch(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +/** Whether the on-disk filename resembles the record's own name for the mod. + * Rescues legitimate adoptions whose mtime corroboration fails because the + * files were copied to a new drive or machine (copies refresh mtimes). */ +function nameCorroborates(fileName: string, mod: InterchangeMod): boolean { + let stem = fileName.replace(/_dir\.vpk$/i, '').replace(/\.vpk$/i, ''); + stem = stem.replace(/^pak\d{2}_?/i, ''); + const normalizedStem = normalizeForMatch(stem); + if (normalizedStem.length < 5) return false; + const sourceFileName = mod.origin.provider === 'gamebanana' ? mod.origin.fileName : undefined; + for (const candidate of [mod.name, sourceFileName]) { + if (!candidate) continue; + const normalized = normalizeForMatch(stripArchiveExt(candidate)); + if (normalized.length < 5) continue; + if (normalizedStem.includes(normalized) || normalized.includes(normalizedStem)) return true; + } + return false; +} + +/** + * Staleness guard for DMM claims. DMM's records claim shared pakNN/.disabled + * slots by bare path, and Grimoire reuses those exact slots, so a record written + * long ago can now point at a completely different mod. A claim is trusted only + * when the file is provably the one DMM recorded: DMM's id prefix is on the + * file, it came from DMM's id-keyed download cache, it is not newer than the + * record, or its name matches the mod. Documents from other sources (bundles) + * carry no claim and are always trusted. + */ +async function claimCorroborated(src: string, mod: InterchangeMod): Promise { + const dmm = dmmExtensionOf(mod); + if (!dmm) return true; + const name = basename(src); + if (name.toLowerCase().startsWith(`${dmm.dmmId.toLowerCase()}_`)) return true; + if (mod.origin.provider === 'gamebanana' && name.startsWith(`${mod.origin.submissionId}_`)) return true; + if (dmm.storeFiles.some((p) => resolve(p) === resolve(src))) return true; + if (dmm.claimMtimeMs > 0) { + try { + const stat = await fs.stat(src); + if (stat.mtimeMs <= dmm.claimMtimeMs + STALE_CLAIM_SLACK_MS) return true; + } catch { + return false; + } + } + return nameCorroborates(name, mod); +} + +/** Whether `src` is already a live, engine-loadable enabled slot: a `*_dir.vpk` + * sitting directly in one of Grimoire's addon roots. Only such a file can be + * adopted in place as enabled; anything else is promoted into a real slot. */ +function isLiveEnabledSlot(src: string, addonRoots: string[]): boolean { + if (!/^pak\d{2,3}_dir\.vpk$/i.test(basename(src))) return false; + const parent = resolve(dirname(src)); + return addonRoots.some((root) => resolve(root) === parent); +} + +/** Whether `src` is a `*_dir.vpk` directly in one of Grimoire's addon roots, + * i.e. a file Grimoire's scan lists as an enabled mod whatever its name. */ +function isInScanRoot(src: string, addonRoots: string[]): boolean { + if (!basename(src).toLowerCase().endsWith('_dir.vpk')) return false; + const parent = resolve(dirname(src)).toLowerCase(); + return addonRoots.some((root) => resolve(root).toLowerCase() === parent); +} + +/** Rename within the game folder; the destination is always free. */ +async function moveFile(src: string, dest: string): Promise { + if (existsSync(dest)) throw new Error(`${basename(dest)} already exists`); + await fs.rename(src, dest); +} + +/** Whether `src` is already a valid disabled slot: a `*_dir.vpk` directly in + * Grimoire's `.disabled` folder. Adopted by metadata only, no move. */ +function isLiveDisabledSlot(src: string, disabledPath: string): boolean { + if (!basename(src).toLowerCase().endsWith('_dir.vpk')) return false; + return resolve(dirname(src)) === resolve(disabledPath); +} + +/** + * Slots are allocated one by one and in-place adoptions keep theirs, so the + * imported mods can end up interleaved in the wrong order. Lay the enabled + * ones out again: Grimoire's existing mods first, untouched in their order, + * then the imported ones in document order. Reordering renames slots, so the + * results are pointed at the new file names afterwards. + */ +async function applyImportedLoadOrder( + deadlockPath: string, + importedEnabled: string[], + results: InterchangeImportResult[] +): Promise { + if (importedEnabled.length === 0) return; + const before = await scanMods(deadlockPath); + const byKey = new Map(before.map((m) => [m.metaKey, m])); + const imported = new Set(importedEnabled); + const loadable = (m: (typeof before)[number]) => m.enabled && !isPriorityFolderPath(m.path); + const existing = before.filter((m) => loadable(m) && !imported.has(m.metaKey)).map((m) => m.id); + const importedIds = importedEnabled + .map((key) => byKey.get(key)) + .filter((m): m is (typeof before)[number] => !!m && loadable(m)) + .map((m) => m.id); + // Already in that order: leave in-place adoptions on the slots they hold. + const desired = [...existing, ...importedIds]; + const wanted = new Set(desired); + const current = before.filter((m) => wanted.has(m.id)).map((m) => m.id); + if (current.every((id, i) => id === desired[i])) return; + const hashByKey = new Map( + results + .flatMap((r) => [r.installedAs, ...(r.installedKeys ?? []), ...(r.variantKeys ?? [])]) + .filter((k): k is string => !!k) + .map((k) => [k, getModMetadata(k)?.sha256?.toLowerCase()]) + ); + try { + await reorderModsUnlocked(deadlockPath, desired); + } catch (err) { + console.warn('[Interchange] could not apply the imported load order:', err); + return; + } + const after = await scanMods(deadlockPath); + const keyByHash = new Map(); + for (const m of after) { + const hash = getModMetadata(m.metaKey)?.sha256?.toLowerCase(); + if (hash && !keyByHash.has(hash)) keyByHash.set(hash, m.metaKey); + } + const remap = (key: string) => { + const hash = hashByKey.get(key); + return (hash && keyByHash.get(hash)) || key; + }; + for (const r of results) { + if (r.installedAs) { + r.installedAs = remap(r.installedAs); + r.modId = generateModId(r.installedAs); + } + if (r.installedKeys) r.installedKeys = r.installedKeys.map(remap); + if (r.variantKeys) r.variantKeys = r.variantKeys.map(remap); + } +} + +/** Ledger records for every result that ended on installed VPKs, by the + * hashes those files have now (after the load-order pass renamed them). */ +function ledgerRecordsOf(results: InterchangeImportResult[]): Record { + const hashes = (keys: string[] | undefined) => + (keys ?? []).map((key) => getModMetadata(key)?.sha256?.toLowerCase()).filter((h): h is string => !!h); + const records: Record = {}; + for (const r of results) { + const loaded = hashes(r.installedKeys); + const sha256 = [...new Set([...loaded, ...hashes(r.variantKeys)])]; + if (sha256.length > 0) records[r.key] = { sha256, loaded }; + } + return records; +} + +export async function importInterchange( + document: InterchangeDocument, + opts: InterchangeImportOptions +): Promise { + const wanted = opts.keys ? new Set(opts.keys) : null; + const selected = document.mods.filter((mod) => !wanted || wanted.has(mod.key)); + const results: InterchangeImportResult[] = []; + const warnings: string[] = []; + const status: Record = {}; + + // Idempotency + validity filters run BEFORE anything is written, so a + // preview and the real import agree, and a re-run is a no-op for + // everything imported last time. + const allMetadata = loadMetadata(); + // Existing file per identity, so skipped entries can still be resolved to + // the VPKs Grimoire already has (profiles reference them). Only entries whose + // file is still installed count: a sidecar entry left by a deleted mod must + // not block that mod from coming back. + const installedMods = await scanMods(opts.deadlockPath); + const installed = new Set(installedMods.map((mod) => mod.metaKey)); + const installedPathByKey = new Map(installedMods.map((mod) => [mod.metaKey, mod.path])); + const installedEnabled = new Map(installedMods.map((mod) => [mod.metaKey, mod.enabled])); + const hashOfKey = (key: string) => allMetadata[key]?.sha256?.toLowerCase(); + const keysBySubmissionId = new Map(); + const keysByHash = new Map(); + const keysByLocalGroup = new Map(); + const managedHashes = new Map(); + const unknownKeyByHash = new Map(); + const push = (map: Map, key: K, value: string) => { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); + }; + for (const [metaKey, meta] of Object.entries(allMetadata)) { + if (!installed.has(metaKey)) continue; + if (meta.gameBananaId !== undefined) push(keysBySubmissionId, meta.gameBananaId, metaKey); + if (meta.localGroupId !== undefined) push(keysByLocalGroup, meta.localGroupId, metaKey); + if (!meta.sha256) continue; + const hash = meta.sha256.toLowerCase(); + push(keysByHash, hash, metaKey); + if (hasModIdentity(meta)) managedHashes.set(hash, metaKey); + else unknownKeyByHash.set(hash, metaKey); + } + + // Rule 6: what earlier imports from this source turned each key into. + // Checked before anything the document says about files, because the + // source's own records go stale once an import has reordered the slots. + const manager = document.source.manager; + const ledger = ledgerModsFor(manager); + const ledgerOwnerByHash = new Map(); + for (const [key, record] of Object.entries(ledger)) { + for (const hash of record.sha256) ledgerOwnerByHash.set(hash, key); + } + const ledgerKeysFor = (mod: InterchangeMod): string[] => [ + ...new Set((ledger[mod.key]?.sha256 ?? []).flatMap((hash) => keysByHash.get(hash) ?? [])), + ]; + + /** Whether an installed file Grimoire already manages is this entry's mod + * and not another one a stale source record happens to point at. */ + const belongsTo = (mod: InterchangeMod, key: string): boolean => { + const meta = getModMetadata(key); + if (!meta) return false; + const hash = meta.sha256?.toLowerCase(); + const owner = hash ? ledgerOwnerByHash.get(hash) : undefined; + if (owner) return owner === mod.key; + return meta.gameBananaId === gameBananaIdOf(mod); + }; + + /** Every installed VPK of the mod `key` belongs to: a local mod's group + * siblings, else just the file itself. */ + const localSiblingsOf = (key: string): string[] => { + const group = allMetadata[key]?.localGroupId; + return group !== undefined ? keysByLocalGroup.get(group) ?? [key] : [key]; + }; + + // A local mod has no id to compare, only its bytes. + const knownLocalFile = async (mod: InterchangeMod): Promise => { + if (mod.origin.provider !== 'local') return undefined; + for (const file of mod.files) { + let hash = file.sha256?.toLowerCase(); + if (!hash && existsSync(file.path)) hash = (await hashFileSha256(file.path).catch(() => ''))?.toLowerCase(); + const existing = hash ? managedHashes.get(hash) : undefined; + if (existing) return existing; + } + return undefined; + }; + + /** + * Split an already installed mod's VPKs into the ones a profile loads and + * the ones it keeps off. Recording only one of them would make a profile + * turn off the rest of a multi-VPK mod, or pick a disabled variant. Loaded + * are, in order of preference: the files matching what the document selects, + * what the ledger recorded as loaded, what Grimoire has enabled now, all. + */ + const splitInstalled = async ( + mod: InterchangeMod, + keys: string[], + record: LedgerModRecord | undefined + ): Promise> => { + const pick = (hashes: Iterable) => { + const set = new Set(hashes); + return keys.filter((key) => { + const hash = hashOfKey(key); + return hash !== undefined && set.has(hash); + }); + }; + let loaded: string[] = []; + if (!opts.planOnly) { + const hasSelection = mod.files.some((f) => f.selected !== false); + const documentHashes: string[] = []; + for (const file of mod.files) { + if (hasSelection && file.selected === false) continue; + let hash = file.sha256?.toLowerCase(); + if (!hash && existsSync(file.path)) hash = (await hashFileSha256(file.path).catch(() => ''))?.toLowerCase(); + if (hash) documentHashes.push(hash); + } + loaded = pick(documentHashes); + } + if (loaded.length === 0 && record) loaded = pick(record.loaded); + if (loaded.length === 0) loaded = keys.filter((key) => installedEnabled.get(key)); + if (loaded.length === 0) loaded = keys; + const variants = keys.filter((key) => !loaded.includes(key)); + return { + installedAs: loaded[0], + installedKeys: loaded, + variantKeys: variants.length > 0 ? variants : undefined, + modId: generateModId(loaded[0]), + }; + }; + + const adoptable: InterchangeMod[] = []; + let unknownToCatalog = 0; + for (const mod of selected) { + const gbId = gameBananaIdOf(mod); + const fromLedger = ledgerKeysFor(mod); + const localExisting = fromLedger.length > 0 ? undefined : await knownLocalFile(mod); + const existingKeys = + fromLedger.length > 0 + ? fromLedger + : localExisting + ? localSiblingsOf(localExisting) + : gbId !== undefined + ? keysBySubmissionId.get(gbId) ?? [] + : []; + if (existingKeys.length > 0) { + status[mod.key] = 'managed'; + results.push({ + key: mod.key, + name: mod.name, + status: 'skipped', + reason: + fromLedger.length > 0 + ? 'already managed by Grimoire (imported earlier)' + : localExisting + ? 'already managed by Grimoire (identical file installed)' + : 'already managed by Grimoire (submission id present in metadata)', + ...(await splitInstalled(mod, existingKeys, ledger[mod.key])), + }); + } else if (gbId !== undefined && opts.isKnownSubmission && !opts.isKnownSubmission(gbId)) { + status[mod.key] = 'unknown-catalog'; + unknownToCatalog++; + results.push({ + key: mod.key, + name: mod.name, + status: 'skipped', + reason: + 'submission id not found in the local GameBanana catalog (wrong id or catalog out of date); left for manual identification', + }); + } else { + status[mod.key] = 'new'; + adoptable.push(mod); + } + } + if (unknownToCatalog > 0) { + warnings.push( + `${unknownToCatalog} mod(s) skipped: their submission id is not in the local GameBanana catalog.` + ); + } + if (opts.planOnly) return { results, warnings, adoptable, status }; + + // Recoverability: back up the metadata sidecar lazily, right before the + // first identity write, so a run that only hits skips never rotates the + // real pre-import backup out of the kept set. + let sidecarBackedUp = false; + const backupSidecarOnce = () => { + if (sidecarBackedUp) return; + sidecarBackedUp = true; + const backupPath = backupMetadataSidecar(opts.backupTag ?? 'pre-interchange-import'); + if (backupPath) console.log(`[Interchange] metadata sidecar backed up to ${backupPath}`); + }; + + // Enabled mods first, ascending load order, so sequential slot allocation + // (pak01, pak02, ...) preserves the source order; then disabled mods. + const ordered = [...adoptable].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.order - b.order; + }); + + const addonRoots = getAddonFolderPaths(opts.deadlockPath); + const disabledPath = getDisabledPath(opts.deadlockPath); + + // Enabled files in document order; laid out after the existing mods at the end. + const importedEnabled: string[] = []; + await runExclusiveModMutation(async () => { + const disabledTaken = new Set( + existsSync(disabledPath) ? (await fs.readdir(disabledPath)).map((n) => n.toLowerCase()) : [] + ); + + for (const [index, mod] of ordered.entries()) { + opts.onProgress?.(index, ordered.length, mod.name); + const meta = metadataFor(mod); + const adoptedKeys: string[] = []; + // The adopted files a profile turns on when it enables this mod. + const loadedKeys: string[] = []; + // Files of this mod Grimoire already had; a profile records them too. + const existingLoaded: string[] = []; + const existingKept: string[] = []; + const fileSkips: string[] = []; + const hasSelection = mod.files.some((f) => f.selected !== false); + const noteExisting = (key: string | undefined, loads: boolean) => { + if (!key || !belongsTo(mod, key)) return; + if (existingLoaded.includes(key) || existingKept.includes(key)) return; + (loads ? existingLoaded : existingKept).push(key); + }; + + for (const file of mod.files) { + const src = file.path; + const label = file.name || basename(src); + // Unselected variants are kept, but never loaded. + const wantEnabled = mod.enabled && (file.selected !== false || !hasSelection); + if (!existsSync(src)) { + fileSkips.push(`${label} (not found on disk)`); + continue; + } + if (!(await claimCorroborated(src, mod))) { + fileSkips.push( + `${label} (file is newer than the DMM record claiming it; possibly a reused slot, left for manual identification)` + ); + continue; + } + + let srcHash: string; + try { + srcHash = (await hashFileSha256(src)).toLowerCase(); + } catch (err) { + fileSkips.push(`${label} (${err instanceof Error ? err.message : String(err)})`); + continue; + } + if (managedHashes.has(srcHash)) { + noteExisting(managedHashes.get(srcHash), file.selected !== false || !hasSelection); + fileSkips.push(`${label} (an identical file is already managed by Grimoire)`); + continue; + } + + try { + await assertVpkSafety(src, { context: 'installation', name: mod.name }); + const twinKey = unknownKeyByHash.get(srcHash); + const twinPath = twinKey ? installedPathByKey.get(twinKey) : undefined; + // A file already sitting where Grimoire scans (the other manager's + // part of the shared addons folder) is a Grimoire mod the moment + // Grimoire looks: adopt it and move it into the state the document + // asks for. A copy would leave the original behind as a second, + // always-enabled mod the game keeps loading. + const inScanArea = isInScanRoot(src, addonRoots); + if (inScanArea) { + const existing = getModMetadata(metaKeyFor(src)); + if (existing && hasModIdentity(existing)) { + noteExisting(metaKeyFor(src), file.selected !== false || !hasSelection); + fileSkips.push(`${label} (already managed by Grimoire)`); + continue; + } + } + let destPath: string; + if (twinPath && resolve(twinPath) !== resolve(src)) { + destPath = twinPath; + } else if (wantEnabled && isLiveEnabledSlot(src, addonRoots)) { + destPath = src; + } else if (wantEnabled) { + // Must write before the next allocation so the slot scan sees it taken. + destPath = await allocateEnabledVpkPath(opts.deadlockPath); + if (inScanArea) await moveFile(src, destPath); + else await fs.copyFile(src, destPath, fsConstants.COPYFILE_EXCL); + } else if (isLiveDisabledSlot(src, disabledPath)) { + destPath = src; + const existing = getModMetadata(metaKeyFor(destPath)); + if (existing && hasModIdentity(existing)) { + noteExisting(metaKeyFor(destPath), file.selected !== false || !hasSelection); + fileSkips.push(`${label} (already managed by Grimoire)`); + continue; + } + } else { + // DMM parks disabled files as `_`; the id means nothing here. + const sourceName = basename(src); + const dmmId = dmmIdFromVpkName(sourceName); + const readable = dmmId ? sourceName.slice(dmmId.length + 1) : sourceName; + const disabledName = makeDisabledFileName(readable, disabledTaken, mod.name); + disabledTaken.add(disabledName.toLowerCase()); + if (!existsSync(disabledPath)) await fs.mkdir(disabledPath, { recursive: true }); + destPath = join(disabledPath, disabledName); + if (inScanArea) await moveFile(src, destPath); + else await fs.copyFile(src, destPath, fsConstants.COPYFILE_EXCL); + } + if (destPath !== src && inScanArea) { + backupSidecarOnce(); + removeModMetadata(metaKeyFor(src)); + } + + const metaKey = metaKeyFor(destPath); + // Clear any orphaned sidecar entry at this key first: an allocated slot + // is only guaranteed free on disk, so a stale entry from a deleted mod + // could otherwise bleed its fields into this one. + backupSidecarOnce(); + removeModMetadata(metaKey); + await setModMetadataWithHash(metaKey, meta, destPath); + managedHashes.set(srcHash, metaKey); + unknownKeyByHash.delete(srcHash); + adoptedKeys.push(metaKey); + if (file.selected !== false || !hasSelection) loadedKeys.push(metaKey); + if (wantEnabled) importedEnabled.push(metaKey); + } catch (err) { + fileSkips.push(`${label} (${err instanceof Error ? err.message : String(err)})`); + } + } + + const local = mod.origin.provider === 'local'; + const loaded = [...loadedKeys, ...existingLoaded]; + const kept = [...adoptedKeys.filter((key) => !loadedKeys.includes(key)), ...existingKept]; + const reason = fileSkips.length > 0 ? fileSkips.join('; ') : undefined; + if (adoptedKeys.length > 0) { + // Nothing selected came across: the profile still needs one file. + if (loaded.length === 0) loaded.push(kept.shift()!); + results.push({ + key: mod.key, + name: mod.name, + status: 'imported', + installedAs: loaded[0], + installedKeys: loaded, + variantKeys: kept.length > 0 ? kept : undefined, + modId: generateModId(loaded[0]), + local, + reason, + }); + } else { + const known = loaded.length > 0 ? loaded : kept; + const variants = loaded.length > 0 ? kept : []; + results.push({ + key: mod.key, + name: mod.name, + status: 'skipped', + reason: reason ?? 'no usable VPK files', + installedAs: known[0], + installedKeys: known.length > 0 ? known : undefined, + variantKeys: variants.length > 0 ? variants : undefined, + modId: known[0] ? generateModId(known[0]) : undefined, + }); + } + } + + await applyImportedLoadOrder(opts.deadlockPath, importedEnabled, results); + }); + + opts.onProgress?.(ordered.length, ordered.length, ''); + recordLedgerMods(manager, ledgerRecordsOf(results)); + const adopted = results.filter((r) => r.status === 'imported').length; + console.log( + `[Interchange] ${document.source.manager} -> Grimoire: ${adopted} adopted, ` + + `${results.length - adopted} skipped` + ); + for (const r of results.filter((r) => r.status !== 'imported')) { + console.log(`[Interchange] skipped ${r.key}: ${r.reason}`); + } + return { results, warnings, adoptable, status }; +} diff --git a/electron/main/services/modInterchange/interchange.test.ts b/electron/main/services/modInterchange/interchange.test.ts new file mode 100644 index 00000000..967dc6bb --- /dev/null +++ b/electron/main/services/modInterchange/interchange.test.ts @@ -0,0 +1,187 @@ +/** + * End-to-end tests for the interchange adapters against a temp sandbox: + * a DMM 2.x install (sharded v3 manifest, sound + local ids, a mod whose addon + * file is gone and only survives in DMM's download cache) imported into + * Grimoire, and a Grimoire library exported and re-imported as a bundle. + * Only app.getPath()/getVersion() are mocked; the real services run. + */ +import { describe, it, expect, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { readDmmLibrary, dmmShardDir } from './dmmReader'; +import { importInterchange } from './importer'; +import { exportGrimoireLibrary, readInterchangeBundle } from './bundle'; + +const h = vi.hoisted(() => ({ userData: '' })); +vi.mock('electron', () => ({ app: { getPath: () => h.userData, getVersion: () => '0.0.0-test' } })); + +const UUID = '0f8fad5b-d9cb-469f-a165-70867728950e'; + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'interchange-')); + const deadlock = join(root, 'Deadlock'); + const citadel = join(deadlock, 'game', 'citadel'); + const addons = join(citadel, 'addons'); + const dmmData = join(root, 'dmm-data'); + const userData = join(root, 'grimoire'); + for (const dir of [addons, join(addons, '.disabled'), dmmData, userData]) mkdirSync(dir, { recursive: true }); + writeFileSync(join(citadel, 'gameinfo.gi'), 'GameInfo {}\n'); + h.userData = userData; + return { root, deadlock, citadel, addons, dmmData, userData }; +} + +function writeDmmState(path: string, mods: unknown[], enabled: Record) { + const enabledMods = Object.fromEntries( + Object.entries(enabled).map(([id, on]) => [id, { remoteId: id, enabled: on }]) + ); + const state = { + activeProfileId: 'default', + localMods: mods, + profiles: { + default: { id: 'default', name: 'Default Profile', isDefault: true, folderName: null, enabledMods, mods }, + }, + }; + writeFileSync(path, JSON.stringify({ 'local-config': JSON.stringify({ state, version: 26 }) })); +} + +const metadata = (userData: string) => + JSON.parse(readFileSync(join(userData, 'mod-metadata.json'), 'utf-8')) as Record< + string, + Record + >; + +describe('DMM 2.x -> Grimoire through the interchange format', () => { + it('reads shards, sound and local ids, and restores lost files from DMM’s cache', async () => { + const sb = sandbox(); + // Shard 2 of the default profile lives in citadel/addons2. + mkdirSync(join(sb.citadel, 'addons2'), { recursive: true }); + writeFileSync(join(sb.addons, 'pak01_dir.vpk'), 'MOD-ONE'); + writeFileSync(join(sb.citadel, 'addons2', 'pak01_dir.vpk'), 'MOD-TWO-SHARD-2'); + writeFileSync(join(sb.addons, 'snd-3_voice_dir.vpk'), 'SOUND-THREE'); + writeFileSync(join(sb.addons, `local-${UUID}_mine_dir.vpk`), 'LOCAL-FOUR'); + // Mod 5's addon file was deleted by hand; only the download cache has it. + mkdirSync(join(sb.dmmData, 'mods', '5', 'files'), { recursive: true }); + writeFileSync(join(sb.dmmData, 'mods', '5', 'files', 'cached_dir.vpk'), 'CACHED-FIVE'); + + writeFileSync( + join(sb.addons, '.dmm.json'), + JSON.stringify({ + version: 3, + mods: { + '1': { enabled: true, order: 0, shard: 1, currentVpks: ['pak01_dir.vpk'], originalVpkNames: ['one.vpk'] }, + '2': { enabled: true, order: 1, shard: 2, currentVpks: ['pak01_dir.vpk'] }, + 'snd-3': { enabled: false, order: 2, disabledVpks: ['snd-3_voice_dir.vpk'] }, + [`local-${UUID}`]: { enabled: false, order: 3, disabledVpks: [`local-${UUID}_mine_dir.vpk`] }, + '5': { enabled: true, order: 4, shard: 1, currentVpks: ['pak02_dir.vpk'] }, + }, + }) + ); + const statePath = join(sb.dmmData, 'state.json'); + writeDmmState( + statePath, + [ + { remoteId: '1', name: 'Mod One', selectedDownloads: [{ url: 'gamebanana-file://1/11', name: 'one.zip' }] }, + { remoteId: '2', name: 'Mod Two' }, + { remoteId: 'snd-3', name: 'Voice Three' }, + { remoteId: `local-${UUID}`, name: 'Mine', author: 'Me', images: ['data:image/svg+xml,x'] }, + { remoteId: '5', name: 'Mod Five', installedFileTree: { files: [{ name: 'cached_dir.vpk', is_selected: true }] } }, + ], + { '1': true, '2': true, 'snd-3': false, [`local-${UUID}`]: false, '5': true } + ); + + const read = await readDmmLibrary({ deadlockPath: sb.deadlock, dmmStatePath: statePath }); + expect(read.document.mods.map((m) => m.key)).toEqual([ + 'gamebanana:mod:1', + 'gamebanana:mod:2', + 'gamebanana:sound:3', + `local:${UUID}`, + 'gamebanana:mod:5', + ]); + expect(read.document.mods[1].files[0].path).toBe(join(sb.citadel, 'addons2', 'pak01_dir.vpk')); + expect(read.document.mods[4].files[0].path).toBe(join(sb.dmmData, 'mods', '5', 'files', 'cached_dir.vpk')); + expect(read.document.warnings.some((w) => w.includes('download cache'))).toBe(true); + + const outcome = await importInterchange(read.document, { deadlockPath: sb.deadlock }); + expect(outcome.results.map((r) => [r.key, r.status])).toEqual([ + ['gamebanana:mod:1', 'imported'], + ['gamebanana:mod:2', 'imported'], + ['gamebanana:mod:5', 'imported'], + ['gamebanana:sound:3', 'imported'], + [`local:${UUID}`, 'imported'], + ]); + const meta = metadata(sb.userData); + expect(meta['pak01_dir.vpk']).toMatchObject({ gameBananaId: 1, gameBananaFileId: 11, modName: 'Mod One' }); + // Mod 5's fresh slot landed ahead of shard 2, so the enabled mods are laid + // out again in document order: 1, 2, 5. + expect(meta['pak02_dir.vpk']).toMatchObject({ gameBananaId: 2 }); + expect(readFileSync(join(sb.addons, 'pak02_dir.vpk'), 'utf-8')).toBe('MOD-TWO-SHARD-2'); + const sound = Object.values(meta).find((m) => m.gameBananaId === 3); + expect(sound).toMatchObject({ sourceSection: 'Sound', modName: 'Voice Three' }); + const local = Object.values(meta).find((m) => m.modName === 'Mine'); + expect(local).toMatchObject({ author: 'Me' }); + expect(local?.gameBananaId).toBeUndefined(); + // Mod 5 came from the cache into a fresh enabled slot. + const five = Object.entries(meta).find(([, m]) => m.gameBananaId === 5); + expect(five?.[0]).toBe('pak03_dir.vpk'); + expect(readFileSync(join(sb.addons, 'pak03_dir.vpk'), 'utf-8')).toBe('CACHED-FIVE'); + + // A second run is a no-op. + const again = await importInterchange(read.document, { deadlockPath: sb.deadlock }); + expect(again.results.every((r) => r.status === 'skipped')).toBe(true); + }); + + it('computes shard folders for default and named profiles', () => { + // An absolute root on every platform: `C:` is a relative segment on Linux. + const citadel = join(tmpdir(), 'g', 'citadel'); + expect(dmmShardDir(join(citadel, 'addons'), 1)).toBe(join(citadel, 'addons')); + expect(dmmShardDir(join(citadel, 'addons'), 3)).toBe(join(citadel, 'addons3')); + expect(dmmShardDir(join(citadel, 'addons', 'profile_x'), 2)).toBe(join(citadel, 'addons2', 'profile_x')); + }); +}); + +describe('Grimoire export -> bundle -> import', () => { + it('round-trips names, identity, variants and order, and rejects escaping paths', async () => { + const source = sandbox(); + writeFileSync(join(source.addons, 'pak01_dir.vpk'), 'SKIN-BLUE'); + writeFileSync(join(source.addons, '.disabled', 'skin_red_dir.vpk'), 'SKIN-RED'); + writeFileSync(join(source.addons, 'pak02_dir.vpk'), 'LOCAL-THING'); + writeFileSync( + join(source.userData, 'mod-metadata.json'), + JSON.stringify({ + 'pak01_dir.vpk': { modName: 'Skin', gameBananaId: 77, gameBananaFileId: 7 }, + 'skin_red_dir.vpk': { modName: 'Skin', gameBananaId: 77 }, + 'pak02_dir.vpk': { modName: 'Thing' }, + }) + ); + const exportDir = join(source.root, 'exports'); + mkdirSync(exportDir); + const exported = await exportGrimoireLibrary(source.deadlock, exportDir); + expect(exported.exported).toBe(2); + + // Tamper: a path escaping the bundle must be dropped. + const manifestPath = join(exported.bundlePath, 'mod-interchange.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + manifest.mods[1].files.push({ name: 'evil.vpk', path: '../../evil.vpk' }); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + const bundle = await readInterchangeBundle(exported.bundlePath); + expect(bundle.source.manager).toBe('grimoire'); + expect(bundle.warnings.some((w) => w.includes('outside the bundle'))).toBe(true); + const skin = bundle.mods.find((m) => m.key === 'gamebanana:mod:77')!; + expect(skin.files.map((f) => f.selected)).toEqual([true, false]); + + // Import into a fresh Grimoire. + const target = sandbox(); + const outcome = await importInterchange(bundle, { deadlockPath: target.deadlock }); + expect(outcome.results.filter((r) => r.status === 'imported')).toHaveLength(2); + const meta = metadata(target.userData); + expect(meta['pak01_dir.vpk']).toMatchObject({ gameBananaId: 77, gameBananaFileId: 7, modName: 'Skin' }); + expect(meta['pak02_dir.vpk']).toMatchObject({ modName: 'Thing' }); + // The unselected variant arrives disabled, same identity. + const disabled = readdirSync(join(target.addons, '.disabled')); + expect(disabled).toHaveLength(1); + expect(meta[disabled[0]]).toMatchObject({ gameBananaId: 77 }); + expect(existsSync(join(target.root, 'evil.vpk'))).toBe(false); + }); +}); diff --git a/electron/main/services/modInterchange/ledger.ts b/electron/main/services/modInterchange/ledger.ts new file mode 100644 index 00000000..a45ee68a --- /dev/null +++ b/electron/main/services/modInterchange/ledger.ts @@ -0,0 +1,112 @@ +/** + * Import ledger (spec rule 6): which document key became which mod, and which + * document profile became which Grimoire profile, per source manager. + * + * Mods are remembered by content hash (the sidecar's canonical sha256), not by + * file name: an import reorders slots, so a file name recorded today names + * another mod tomorrow, and so do the source manager's own records. Hashes + * survive slot moves and a later GameBanana link of a local mod. + * + * Lives in userData as `interchange-ledger.json`. A missing or unreadable file + * only means every entry is treated as new, as before the ledger existed. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; + +import { getUserDataPath } from '../../utils/paths'; + +export interface LedgerModRecord { + /** Hashes of every VPK the entry became, loaded or kept as a variant. */ + sha256: string[]; + /** The subset a profile turns on when it enables this entry. */ + loaded: string[]; +} + +interface LedgerSource { + mods: Record; + /** Document profile key -> Grimoire profile id. */ + profiles: Record; +} + +interface LedgerFile { + version: 1; + sources: Record; +} + +function ledgerPath(): string { + return join(getUserDataPath(), 'interchange-ledger.json'); +} + +const isObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const hashList = (v: unknown): string[] => + Array.isArray(v) ? v.filter((h): h is string => typeof h === 'string' && !!h).map((h) => h.toLowerCase()) : []; + +function loadLedger(): LedgerFile { + const empty: LedgerFile = { version: 1, sources: {} }; + try { + if (!existsSync(ledgerPath())) return empty; + const raw = JSON.parse(readFileSync(ledgerPath(), 'utf-8')) as unknown; + if (!isObject(raw) || !isObject(raw.sources)) return empty; + const sources: Record = {}; + for (const [manager, value] of Object.entries(raw.sources)) { + if (!isObject(value)) continue; + const mods: Record = {}; + for (const [key, record] of Object.entries(isObject(value.mods) ? value.mods : {})) { + if (!isObject(record)) continue; + const sha256 = hashList(record.sha256); + if (sha256.length > 0) mods[key] = { sha256, loaded: hashList(record.loaded) }; + } + const profiles: Record = {}; + for (const [key, id] of Object.entries(isObject(value.profiles) ? value.profiles : {})) { + if (typeof id === 'string' && id) profiles[key] = id; + } + sources[manager] = { mods, profiles }; + } + return { version: 1, sources }; + } catch (err) { + console.warn('[Interchange] import ledger unreadable, treating every entry as new:', err); + return empty; + } +} + +function saveLedger(ledger: LedgerFile): void { + const path = ledgerPath(); + const tempPath = `${path}.tmp`; + try { + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true }); + writeFileSync(tempPath, JSON.stringify(ledger, null, 2), 'utf-8'); + renameSync(tempPath, path); + } catch (err) { + // A lost ledger write only costs the next re-import its memory. + console.warn('[Interchange] could not save the import ledger:', err); + } +} + +function sourceOf(ledger: LedgerFile, manager: string): LedgerSource { + ledger.sources[manager] ??= { mods: {}, profiles: {} }; + return ledger.sources[manager]; +} + +export function ledgerModsFor(manager: string): Record { + return loadLedger().sources[manager]?.mods ?? {}; +} + +export function recordLedgerMods(manager: string, mods: Record): void { + if (Object.keys(mods).length === 0) return; + const ledger = loadLedger(); + Object.assign(sourceOf(ledger, manager).mods, mods); + saveLedger(ledger); +} + +export function ledgerProfileId(manager: string, profileKey: string): string | undefined { + return loadLedger().sources[manager]?.profiles[profileKey]; +} + +export function recordLedgerProfile(manager: string, profileKey: string, profileId: string): void { + const ledger = loadLedger(); + sourceOf(ledger, manager).profiles[profileKey] = profileId; + saveLedger(ledger); +} diff --git a/electron/main/services/modInterchange/sections.test.ts b/electron/main/services/modInterchange/sections.test.ts new file mode 100644 index 00000000..824d7d06 --- /dev/null +++ b/electron/main/services/modInterchange/sections.test.ts @@ -0,0 +1,343 @@ +/** + * The optional interchange sections: DMM profiles and crosshairs read into + * the document, and a selection import that recreates profiles and presets + * in Grimoire. Only app.getPath()/getVersion() are mocked. + */ +import { describe, it, expect, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { readDmmDocument } from './dmmReader'; +import { importInterchangeSelection, uniqueName } from './selectionImport'; +import { + crosshairConvarsFromSettings, + crosshairSettingsFromConvars, +} from '../../../../src/lib/crosshair'; + +const h = vi.hoisted(() => ({ userData: '' })); +vi.mock('electron', () => ({ app: { getPath: () => h.userData, getVersion: () => '0.0.0-test' } })); + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), 'interchange-sections-')); + const deadlock = join(root, 'Deadlock'); + const addons = join(deadlock, 'game', 'citadel', 'addons'); + const userData = join(root, 'grimoire'); + for (const dir of [addons, join(addons, '.disabled'), userData]) mkdirSync(dir, { recursive: true }); + writeFileSync(join(deadlock, 'game', 'citadel', 'gameinfo.gi'), 'GameInfo {}\n'); + h.userData = userData; + return { root, deadlock, addons, userData }; +} + +describe('crosshair convars', () => { + it('round-trips Grimoire settings and accepts DMM’s single border flag', () => { + const settings = crosshairSettingsFromConvars({ + citadel_crosshair_pip_gap: '4', + citadel_crosshair_color_r: '10', + citadel_crosshair_pip_gap_static: 'true', + })!; + expect(settings.pipGap).toBe(4); + expect(settings.pipGapStatic).toBe(true); + expect(crosshairSettingsFromConvars(crosshairConvarsFromSettings(settings))).toEqual(settings); + expect(crosshairSettingsFromConvars({ citadel_crosshair_pip_border: 'true' })?.pipOutlineBorder).toBe(1); + expect(crosshairSettingsFromConvars({ unrelated: '1' })).toBeNull(); + }); +}); + +describe('uniqueName', () => { + it('never reuses a profile name', () => { + expect(uniqueName('Ranked', ['Other'])).toBe('Ranked'); + expect(uniqueName('Ranked', ['ranked', 'Ranked (2)'])).toBe('Ranked (3)'); + }); +}); + +describe('DMM with several profiles', () => { + it('reads every profile, unions the library and reads crosshairs', async () => { + const sb = sandbox(); + const folder = 'profile_2_ranked'; + mkdirSync(join(sb.addons, folder), { recursive: true }); + writeFileSync(join(sb.addons, 'pak01_dir.vpk'), 'SHARED'); + writeFileSync(join(sb.addons, folder, 'pak01_dir.vpk'), 'SHARED-COPY'); + writeFileSync(join(sb.addons, folder, 'pak02_dir.vpk'), 'RANKED-ONLY'); + writeFileSync( + join(sb.addons, '.dmm.json'), + JSON.stringify({ version: 3, mods: { '1': { enabled: true, order: 0, shard: 1, currentVpks: ['pak01_dir.vpk'] } } }) + ); + writeFileSync( + join(sb.addons, folder, '.dmm.json'), + JSON.stringify({ + version: 3, + mods: { + '1': { enabled: false, order: 0, shard: 1, currentVpks: ['pak01_dir.vpk'] }, + '2': { enabled: true, order: 1, shard: 1, currentVpks: ['pak02_dir.vpk'] }, + }, + }) + ); + const statePath = join(sb.root, 'state.json'); + const mods = [ + { remoteId: '1', name: 'Shared' }, + { remoteId: '2', name: 'Ranked Only' }, + ]; + const state = { + activeProfileId: 'default', + localMods: mods, + activeCrosshair: { gap: 3, width: 4, height: 16, color: { r: 1, g: 2, b: 3 }, pipBorder: true }, + activeCrosshairHistory: [ + { gap: 3, width: 4, height: 16, color: { r: 1, g: 2, b: 3 }, pipBorder: true }, + { gap: 9, color: { r: 9, g: 9, b: 9 } }, + ], + profiles: { + default: { id: 'default', name: 'Default Profile', isDefault: true, folderName: null, enabledMods: { '1': { enabled: true } }, mods: [mods[0]] }, + ranked: { id: 'ranked', name: 'Ranked', isDefault: false, folderName: folder, enabledMods: { '2': { enabled: true } }, mods }, + broken: { id: 'broken', name: 'Broken', isDefault: false, folderName: 'missing_folder', enabledMods: {}, mods: [] }, + }, + }; + writeFileSync(statePath, JSON.stringify({ 'local-config': JSON.stringify({ state, version: 26 }) })); + + const document = await readDmmDocument({ deadlockPath: sb.deadlock, dmmStatePath: statePath }); + expect(document.contents).toEqual(['mods', 'profiles', 'crosshairs']); + expect(document.mods.map((m) => [m.key, m.enabled])).toEqual([ + ['gamebanana:mod:1', true], + ['gamebanana:mod:2', false], + ]); + const ranked = document.profiles.find((p) => p.name === 'Ranked')!; + expect(ranked.active).toBe(false); + expect(ranked.mods.map((m) => [m.modKey, m.enabled])).toEqual([ + ['gamebanana:mod:1', false], + ['gamebanana:mod:2', true], + ]); + expect(document.profiles.find((p) => p.name === 'Default Profile')!.active).toBe(true); + // Two distinct crosshairs; the duplicate history entry collapses. + expect(document.crosshairs).toHaveLength(2); + expect(document.crosshairs[0]).toMatchObject({ active: true }); + expect(document.crosshairs[0].convars.citadel_crosshair_pip_border).toBe('true'); + }); +}); + +describe('importInterchangeSelection', () => { + it('recreates profiles with their mods and adds crosshair presets', async () => { + const sb = sandbox(); + const bundle = join(sb.root, 'bundle'); + mkdirSync(bundle); + writeFileSync(join(bundle, 'a_dir.vpk'), 'MOD-A'); + writeFileSync(join(bundle, 'b_dir.vpk'), 'MOD-B'); + writeFileSync( + join(sb.userData, 'profiles.json'), + JSON.stringify([{ id: 'x', name: 'Ranked', mods: [], createdAt: '', updatedAt: '' }]) + ); + const mod = (key: string, name: string, file: string) => ({ + key, + name, + enabled: true, + order: 0, + origin: { provider: 'local' as const }, + files: [{ name: file, path: join(bundle, file) }], + }); + + const report = await importInterchangeSelection( + { + format: 'deadlock-mod-interchange', + version: 1, + source: { manager: 'test' }, + contents: ['mods', 'profiles', 'crosshairs'], + mods: [mod('local:a', 'Mod A', 'a_dir.vpk'), mod('local:b', 'Mod B', 'b_dir.vpk')], + profiles: [ + { + key: 'p', + name: 'Ranked', + active: false, + mods: [ + { modKey: 'local:b', enabled: true, order: 0 }, + { modKey: 'local:gone', enabled: true, order: 1 }, + ], + crosshairKey: 'c1', + autoexec: ['fps_max 240'], + }, + ], + crosshairs: [ + { key: 'c1', name: 'Profile crosshair', active: false, convars: { citadel_crosshair_pip_gap: '7' } }, + { key: 'c2', name: 'Dot', active: true, convars: { citadel_crosshair_dot_size: '3' } }, + ], + warnings: [], + }, + // Only mod A for the library; the profile still needs mod B. + { modKeys: ['local:a'], profileKeys: ['p'], crosshairKeys: ['c2'] }, + { deadlockPath: sb.deadlock } + ); + + expect(report.results.filter((r) => r.status === 'imported').map((r) => r.key).sort()).toEqual([ + 'local:a', + 'local:b', + ]); + expect(report.results.every((r) => r.local && r.modId)).toBe(true); + // The dangling entry is dropped with a reason, never guessed. + expect(report.profiles).toHaveLength(1); + expect(report.profiles[0]).toMatchObject({ name: 'Ranked (2)', created: true, mods: 1 }); + expect(report.profiles[0].reason).toContain('left out'); + const profiles = JSON.parse(readFileSync(join(sb.userData, 'profiles.json'), 'utf-8')); + const created = profiles.find((p: { name: string }) => p.name === 'Ranked (2)'); + expect(created.mods).toHaveLength(1); + expect(created.mods[0]).toMatchObject({ enabled: true }); + expect(created.crosshair.pipGap).toBe(7); + expect(created.autoexecCommands).toEqual(['fps_max 240']); + // Mod B is only in the profile: imported disabled, so the library keeps + // the source's library state. + const meta = JSON.parse(readFileSync(join(sb.userData, 'mod-metadata.json'), 'utf-8')); + const bKey = report.results.find((r) => r.key === 'local:b')!.installedAs!; + expect(bKey.endsWith('_dir.vpk') && !/^pak/.test(bKey)).toBe(true); + expect(meta[bKey].modName).toBe('Mod B'); + + expect(report.crosshairs).toBe(1); + const presets = JSON.parse(readFileSync(join(sb.userData, 'crosshair-presets.json'), 'utf-8')); + expect(presets.presets).toHaveLength(1); + expect(presets.presets[0]).toMatchObject({ name: 'Dot' }); + expect(presets.presets[0].settings.dotSize).toBe(3); + }); +}); + +describe('re-running an import', () => { + const doc = (bundle: string, files: Record) => ({ + format: 'deadlock-mod-interchange' as const, + version: 1, + source: { manager: 'test' }, + contents: ['mods' as const, 'profiles' as const], + mods: Object.entries(files).map(([key, file], order) => ({ + key, + name: key, + enabled: true, + order, + origin: { provider: 'local' as const }, + files: [{ name: file, path: join(bundle, file) }], + })), + profiles: [ + { + key: 'p', + name: 'Ranked', + active: false, + mods: Object.keys(files).map((modKey, order) => ({ modKey, enabled: true, order })), + }, + ], + crosshairs: [], + warnings: [], + }); + + it('refreshes the profile it created and follows the ledger over stale records', async () => { + const sb = sandbox(); + const bundle = join(sb.root, 'bundle'); + mkdirSync(bundle); + writeFileSync(join(bundle, 'a_dir.vpk'), 'MOD-A'); + writeFileSync(join(bundle, 'b_dir.vpk'), 'MOD-B'); + const selection = { modKeys: ['local:a', 'local:b'], profileKeys: ['p'], crosshairKeys: [] }; + + const first = await importInterchangeSelection( + doc(bundle, { 'local:a': 'a_dir.vpk', 'local:b': 'b_dir.vpk' }), + selection, + { deadlockPath: sb.deadlock } + ); + expect(first.profiles[0]).toMatchObject({ name: 'Ranked', created: true }); + expect(first.profiles[0].updated).toBeUndefined(); + + // The source's record for A now points at B's file (a reused slot). + const second = await importInterchangeSelection( + doc(bundle, { 'local:a': 'b_dir.vpk', 'local:b': 'b_dir.vpk' }), + selection, + { deadlockPath: sb.deadlock } + ); + expect(second.results.every((r) => r.status === 'skipped')).toBe(true); + expect(second.profiles[0]).toMatchObject({ name: 'Ranked', created: true, updated: true, mods: 2 }); + + const profiles = JSON.parse(readFileSync(join(sb.userData, 'profiles.json'), 'utf-8')); + expect(profiles.map((p: { name: string }) => p.name)).toEqual(['Ranked']); + const fileNames = profiles[0].mods.map((m: { fileName: string }) => m.fileName); + expect(new Set(fileNames).size).toBe(2); + const keyOf = (r: { key: string }) => second.results.find((x) => x.key === r.key)!.installedAs; + expect(keyOf({ key: 'local:a' })).toBe(first.results.find((r) => r.key === 'local:a')!.installedAs); + }); + + it('records every installed VPK of an already managed mod', async () => { + const sb = sandbox(); + writeFileSync(join(sb.addons, 'pak01_dir.vpk'), 'PART-ONE'); + writeFileSync(join(sb.addons, 'pak02_dir.vpk'), 'PART-TWO-LONGER'); + writeFileSync(join(sb.addons, '.disabled', 'variant_dir.vpk'), 'OTHER-VARIANT'); + const meta = { modName: 'Pack', gameBananaId: 42, gameBananaFileId: 7 }; + writeFileSync( + join(sb.userData, 'mod-metadata.json'), + JSON.stringify({ + 'pak01_dir.vpk': meta, + 'pak02_dir.vpk': meta, + 'variant_dir.vpk': meta, + }) + ); + const report = await importInterchangeSelection( + { + format: 'deadlock-mod-interchange', + version: 1, + source: { manager: 'test' }, + contents: ['mods', 'profiles'], + mods: [ + { + key: 'gamebanana:mod:42', + name: 'Pack', + enabled: true, + order: 0, + origin: { provider: 'gamebanana', submissionType: 'mod', submissionId: '42' }, + files: [{ name: 'gone_dir.vpk', path: join(sb.root, 'gone_dir.vpk') }], + }, + ], + profiles: [ + { key: 'p', name: 'Pack only', active: false, mods: [{ modKey: 'gamebanana:mod:42', enabled: true, order: 0 }] }, + ], + crosshairs: [], + warnings: [], + }, + { modKeys: [], profileKeys: ['p'], crosshairKeys: [] }, + { deadlockPath: sb.deadlock } + ); + expect(report.results[0].status).toBe('skipped'); + const profile = JSON.parse(readFileSync(join(sb.userData, 'profiles.json'), 'utf-8'))[0]; + const state = Object.fromEntries( + profile.mods.map((m: { fileName: string; enabled: boolean }) => [m.fileName, m.enabled]) + ); + expect(state).toEqual({ 'pak01_dir.vpk': true, 'pak02_dir.vpk': true, 'variant_dir.vpk': false }); + }); +}); + +describe('DMM archive names', () => { + it('looks them up in the download folder only, never in a live slot', async () => { + const sb = sandbox(); + // An unrelated mod holds the live pak01 slot. + writeFileSync(join(sb.addons, 'pak01_dir.vpk'), 'SOMEONE-ELSE'); + writeFileSync( + join(sb.addons, '.dmm.json'), + JSON.stringify({ + version: 3, + mods: { + '5': { enabled: true, order: 0, shard: 1 }, + '6': { enabled: true, order: 1, shard: 1 }, + }, + }) + ); + const statePath = join(sb.root, 'state.json'); + const tree = { files: [{ name: 'pak01_dir.vpk', is_selected: true }] }; + const mods = [ + { remoteId: '5', name: 'Stored', installedFileTree: tree }, + { remoteId: '6', name: 'Not stored', installedFileTree: tree }, + ]; + const state = { + activeProfileId: 'default', + localMods: mods, + profiles: { + default: { id: 'default', name: 'Default', isDefault: true, folderName: null, enabledMods: {}, mods }, + }, + }; + writeFileSync(statePath, JSON.stringify({ 'local-config': JSON.stringify({ state, version: 26 }) })); + const stored = join(sb.root, 'mods', '5', 'files', 'pak01_dir.vpk'); + mkdirSync(join(sb.root, 'mods', '5', 'files'), { recursive: true }); + writeFileSync(stored, 'STORED'); + + const document = await readDmmDocument({ deadlockPath: sb.deadlock, dmmStatePath: statePath }); + expect(document.mods.map((m) => [m.key, m.files.map((f) => f.path)])).toEqual([ + ['gamebanana:mod:5', [stored]], + ]); + expect(document.warnings.some((w) => w.includes('Not stored'))).toBe(true); + }); +}); diff --git a/electron/main/services/modInterchange/selectionImport.ts b/electron/main/services/modInterchange/selectionImport.ts new file mode 100644 index 00000000..3e9ecb65 --- /dev/null +++ b/electron/main/services/modInterchange/selectionImport.ts @@ -0,0 +1,231 @@ +/** + * The full interchange import the wizard runs: the chosen mods, then the + * chosen profiles, then crosshairs. Each section is optional, so a source + * without profiles (or an import that skips them) simply leaves them out. + * + * Grimoire profiles are snapshots of which installed VPKs are enabled, so a + * profile can only point at mods Grimoire has. Mods a selected profile needs + * are therefore imported too, disabled unless the library selection enables + * them; a profile entry whose mod could not be imported is dropped, never + * replaced by a guess. + * + * A profile an earlier import of the same source created is refreshed in + * place (the import ledger maps it), so re-running the import never stacks + * up "Ranked (2)", "Ranked (3)" copies. + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +import { getUserDataPath } from '../../utils/paths'; +import { scanMods } from '../mods'; +import { + addProfile, + generateProfileId, + loadProfiles, + profileModsForKeys, + replaceProfile, +} from '../profiles'; +import { + crosshairSettingsFromConvars, + normalizeCrosshairSettings, +} from '../../../../src/lib/crosshair'; +import type { + InterchangeDocument, + InterchangeImportReport, + InterchangeImportSelection, + InterchangeProgress, +} from '../../../../src/lib/modInterchange'; +import type { CrosshairPreset, ProfileMod } from '../../../../src/types/electron'; +import { importInterchange, type InterchangeImportOptions } from './importer'; +import { ledgerProfileId, recordLedgerProfile } from './ledger'; + +export interface SelectionImportOptions + extends Omit { + onProgress?: (progress: InterchangeProgress) => void; +} + +/** `name`, or `name (2)`, `name (3)` ... when a profile already uses it. */ +export function uniqueName(name: string, taken: readonly string[]): string { + const used = new Set(taken.map((n) => n.trim().toLowerCase())); + const base = name.trim() || 'Imported profile'; + if (!used.has(base.toLowerCase())) return base; + for (let n = 2; ; n++) { + const candidate = `${base} (${n})`; + if (!used.has(candidate.toLowerCase())) return candidate; + } +} + +interface PresetsFile { + presets: CrosshairPreset[]; + activePresetId: string | null; +} + +function presetsPath(): string { + return join(getUserDataPath(), 'crosshair-presets.json'); +} + +function loadPresets(): PresetsFile { + try { + if (existsSync(presetsPath())) { + const parsed = JSON.parse(readFileSync(presetsPath(), 'utf-8')) as Partial; + return { + presets: Array.isArray(parsed.presets) ? parsed.presets : [], + activePresetId: parsed.activePresetId ?? null, + }; + } + } catch (err) { + console.warn('[Interchange] crosshair presets unreadable, starting fresh:', err); + } + return { presets: [], activePresetId: null }; +} + +export async function importInterchangeSelection( + document: InterchangeDocument, + selection: InterchangeImportSelection, + opts: SelectionImportOptions +): Promise { + const profiles = document.profiles.filter((p) => selection.profileKeys.includes(p.key)); + const libraryKeys = new Set(selection.modKeys); + const profileOnly = new Set( + profiles.flatMap((p) => p.mods.map((m) => m.modKey)).filter((key) => !libraryKeys.has(key)) + ); + const scoped: InterchangeDocument = { + ...document, + mods: document.mods.map((mod) => (profileOnly.has(mod.key) ? { ...mod, enabled: false } : mod)), + }; + + const outcome = await importInterchange(scoped, { + ...opts, + keys: [...libraryKeys, ...profileOnly], + onProgress: (current, total, name) => + opts.onProgress?.({ stage: 'mods', current, total, name }), + }); + // Entry key -> the VPKs a profile turns on, and the mod's other files it + // keeps off. All of them are recorded: an entry naming only one would make + // applying the profile disable the rest of the mod. + const filesByKey = new Map( + outcome.results + .filter((r) => r.installedAs) + .map((r) => [ + r.key, + { + loaded: r.installedKeys?.length ? r.installedKeys : [r.installedAs as string], + kept: r.variantKeys ?? [], + }, + ]) + ); + + const report: InterchangeImportReport = { + results: outcome.results, + profiles: [], + crosshairs: 0, + warnings: [...outcome.warnings], + }; + + const manager = document.source.manager; + const installed = profiles.length > 0 ? await scanMods(opts.deadlockPath) : []; + const crosshairsByKey = new Map(document.crosshairs.map((c) => [c.key, c])); + const takenNames = loadProfiles().map((p) => p.name); + for (const [index, profile] of profiles.entries()) { + opts.onProgress?.({ stage: 'profiles', current: index, total: profiles.length, name: profile.name }); + // Grimoire profiles list the files to turn on, in load order. Each file + // appears once: an enabled claim wins over a kept-off one. + const picks = new Map(); + const pick = (metaKey: string, enabled: boolean, priority: number) => { + const existing = picks.get(metaKey); + if (!existing) picks.set(metaKey, { metaKey, enabled, priority }); + else if (enabled && !existing.enabled) picks.set(metaKey, { metaKey, enabled, priority }); + }; + let dropped = 0; + const ordered = [...profile.mods].sort((a, b) => a.order - b.order); + for (const [position, entry] of ordered.entries()) { + const files = filesByKey.get(entry.modKey); + if (!files) { + dropped++; + continue; + } + for (const metaKey of files.loaded) pick(metaKey, entry.enabled, position); + for (const metaKey of files.kept) pick(metaKey, false, position); + } + // Matched back by GameBanana ids, then by content hash (the only stable + // identity a local mod has), so both are recorded with the file name. + const mods: ProfileMod[] = profileModsForKeys(installed, [...picks.values()]); + const crosshairConvars = profile.crosshairKey + ? crosshairsByKey.get(profile.crosshairKey)?.convars + : undefined; + const crosshair = crosshairConvars ? crosshairSettingsFromConvars(crosshairConvars) : null; + const now = new Date().toISOString(); + const reason = dropped > 0 ? `${dropped} mod(s) could not be imported and were left out` : undefined; + const previousId = ledgerProfileId(manager, profile.key); + const previous = previousId ? loadProfiles().find((p) => p.id === previousId) : undefined; + try { + if (previous) { + replaceProfile({ + ...previous, + mods, + ...(crosshair ? { crosshair } : {}), + ...(profile.autoexec?.length ? { autoexecCommands: profile.autoexec } : {}), + updatedAt: now, + }); + report.profiles.push({ name: previous.name, created: true, updated: true, mods: mods.length, reason }); + continue; + } + const name = uniqueName(profile.name, takenNames); + takenNames.push(name); + const created = addProfile({ + id: generateProfileId(), + name, + mods, + ...(crosshair ? { crosshair } : {}), + ...(profile.autoexec?.length ? { autoexecCommands: profile.autoexec } : {}), + createdAt: now, + updatedAt: now, + }); + recordLedgerProfile(manager, profile.key, created.id); + report.profiles.push({ name, created: true, mods: mods.length, reason }); + } catch (err) { + report.profiles.push({ + name: previous?.name ?? profile.name, + created: false, + mods: 0, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + const crosshairs = document.crosshairs.filter((c) => selection.crosshairKeys.includes(c.key)); + if (crosshairs.length > 0) { + const file = loadPresets(); + const existing = new Set( + file.presets.map((p) => JSON.stringify(normalizeCrosshairSettings(p.settings))) + ); + crosshairs.forEach((crosshair, index) => { + opts.onProgress?.({ + stage: 'crosshairs', + current: index, + total: crosshairs.length, + name: crosshair.name, + }); + const settings = crosshairSettingsFromConvars(crosshair.convars); + if (!settings) return; + const fingerprint = JSON.stringify(settings); + if (existing.has(fingerprint)) return; + existing.add(fingerprint); + file.presets.push({ + id: `preset_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, + name: uniqueName( + crosshair.name, + file.presets.map((p) => p.name) + ), + settings, + thumbnail: '', + createdAt: new Date().toISOString(), + }); + report.crosshairs++; + }); + if (report.crosshairs > 0) writeFileSync(presetsPath(), JSON.stringify(file, null, 2)); + } + + return report; +} diff --git a/electron/main/services/mods.ts b/electron/main/services/mods.ts index a9d3c7a9..8129c903 100644 --- a/electron/main/services/mods.ts +++ b/electron/main/services/mods.ts @@ -231,7 +231,7 @@ function parseVpkPriority(filename: string): number | null { * and `addons{N}/` for overflow mods, which keeps IDs unique when the same * pakNN_dir.vpk name exists in more than one addon folder. */ -function generateModId(metaKey: string): string { +export function generateModId(metaKey: string): string { return createHash('md5').update(metaKey).digest('hex').slice(0, 16); } diff --git a/electron/main/services/profiles.ts b/electron/main/services/profiles.ts index 21797853..94b6fc65 100644 --- a/electron/main/services/profiles.ts +++ b/electron/main/services/profiles.ts @@ -56,6 +56,37 @@ export function addProfile(profile: Profile): Profile { return profile; } +/** + * Replace a stored profile by id. Used by re-imports that refresh a profile + * an earlier import created. + */ +export function replaceProfile(profile: Profile): Profile { + const profiles = loadProfiles(); + const index = profiles.findIndex((p) => p.id === profile.id); + if (index === -1) throw new Error(`Profile not found: ${profile.id}`); + profiles[index] = profile; + saveProfiles(profiles); + return profile; +} + +/** + * Profile entries for chosen installed VPKs, with the same stable ids and + * VPK indexes a saved profile carries. `installed` is the full scan, so + * multi-VPK indexes are inferred the way applyProfile's resolver infers them. + */ +export function profileModsForKeys( + installed: Array<{ metaKey: string; fileName: string; size: number }>, + picks: Array<{ metaKey: string; enabled: boolean; priority: number }> +): ProfileMod[] { + const byKey = new Map(installed.map((mod) => [mod.metaKey, mod])); + const inferredVpkIndexes = inferMissingVpkIndexes(installed); + return picks.flatMap((pick) => { + const mod = byKey.get(pick.metaKey); + if (!mod) return []; + return [toProfileMod({ ...mod, priority: pick.priority }, pick.enabled, inferredVpkIndexes)]; + }); +} + /** * Load all profiles from disk */ diff --git a/electron/main/services/snapshots.ts b/electron/main/services/snapshots.ts index 37927e38..5733928f 100644 --- a/electron/main/services/snapshots.ts +++ b/electron/main/services/snapshots.ts @@ -119,6 +119,8 @@ export async function writeSnapshot( ? `Auto-snapshot (before update) ${friendlyTimestamp}` : trigger === 'pre-dmm-import' ? `Auto-snapshot (before DMM import) ${friendlyTimestamp}` + : trigger === 'pre-mod-import' + ? `Auto-snapshot (before mod import) ${friendlyTimestamp}` : `Snapshot ${friendlyTimestamp}`; const { profile, warnings } = await buildPortableProfileFromInstalled( diff --git a/electron/preload/index.ts b/electron/preload/index.ts index eb7b1420..8bb1c6a4 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -76,7 +76,13 @@ import type { } from '../../src/types/electron'; import type { AppearanceSurface } from '../../src/types/mod'; import type { DeadworksConnectProgress } from '../../src/types/deadworks'; -import type { DmmMigrationRequest } from '../../src/lib/dmmMigration'; +import type { + InterchangeDocument, + InterchangeExportSelection, + InterchangeImportSelection, + InterchangeProgress, + InterchangeSourceRequest, +} from '../../src/lib/modInterchange'; import type { ProfileSort, PublishRequest, @@ -99,10 +105,19 @@ contextBridge.exposeInMainWorld('electronAPI', { getSettings: () => ipcRenderer.invoke('get-settings'), setSettings: (settings: AppSettings) => ipcRenderer.invoke('set-settings', settings), - // Deadlock Mod Manager migration (adopt DMM's on-disk VPKs; no cloud) - dmmMigrate: { - scan: (req: DmmMigrationRequest) => ipcRenderer.invoke('dmm-migrate:scan', req), - execute: (req: DmmMigrationRequest) => ipcRenderer.invoke('dmm-migrate:execute', req), + // Mod transfer between mod managers (neutral interchange format; no cloud) + interchange: { + sources: () => ipcRenderer.invoke('interchange:sources'), + read: (req: InterchangeSourceRequest) => ipcRenderer.invoke('interchange:read', req), + import: (req: { document: InterchangeDocument; selection: InterchangeImportSelection }) => + ipcRenderer.invoke('interchange:import', req), + export: (req: { destinationDir: string; selection: InterchangeExportSelection }) => + ipcRenderer.invoke('interchange:export', req), + onProgress: (callback: (progress: InterchangeProgress) => void) => { + const listener = (_: unknown, progress: InterchangeProgress) => callback(progress); + ipcRenderer.on('interchange:progress', listener); + return () => ipcRenderer.removeListener('interchange:progress', listener); + }, }, // Discord Rich Presence (opt-in; talks only to the local Discord client) diff --git a/src/components/interchange/ExportModal.tsx b/src/components/interchange/ExportModal.tsx new file mode 100644 index 00000000..adf78d5e --- /dev/null +++ b/src/components/interchange/ExportModal.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, FolderOpen } from 'lucide-react'; +import { exportModsForOtherManagers, onInterchangeProgress, showOpenDialog, revealPath } from '../../lib/api'; +import type { + InterchangeExportReport, + InterchangeExportSelection, + InterchangeProgress, +} from '../../lib/modInterchange'; +import { showToast } from '../../stores/toastStore'; +import { Modal } from '../common/Modal'; +import { Button, CheckboxMark, ModalHeader } from '../common/ui'; + +interface ExportModalProps { + onClose: () => void; +} + +/** Export for other mod managers: choose what to include, where to save, + * watch it copy, then open the result. */ +export default function ExportModal({ onClose }: ExportModalProps) { + const { t } = useTranslation(); + const [selection, setSelection] = useState({ + profiles: true, + crosshairs: true, + }); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [report, setReport] = useState(null); + + useEffect(() => onInterchangeProgress(setProgress), []); + + const run = async () => { + const destination = await showOpenDialog({ directory: true, title: t('interchange.exportPickFolder') }); + if (!destination) return; + setRunning(true); + try { + setReport(await exportModsForOtherManagers(destination, selection)); + } catch (err) { + showToast( + t('interchange.exportFailed', { error: err instanceof Error ? err.message : String(err) }), + { tone: 'error', duration: 8000, dismissable: true } + ); + } finally { + setRunning(false); + } + }; + + const option = (key: keyof InterchangeExportSelection, title: string, detail: string) => ( + + ); + + const percent = progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + + return ( + + +
+ {!report && !running && ( + <> +
+ +
+

{t('interchange.sectionMods')}

+

{t('interchange.exportModsDetail')}

+
+
+ {option('profiles', t('interchange.sectionProfiles'), t('interchange.exportProfilesDetail'))} + {option('crosshairs', t('interchange.sectionCrosshairs'), t('interchange.exportCrosshairsDetail'))} + + )} + {running && ( +
+
+
+
+

+ {progress + ? t('interchange.exporting', { current: Math.min(progress.current + 1, progress.total), total: progress.total }) + : t('interchange.progress.starting')} +

+
+ )} + {report && ( +
+
+ + {t('interchange.exportedSummary', { + mods: report.exported, + profiles: report.profiles, + crosshairs: report.crosshairs, + })} +
+

{report.bundlePath}

+ {report.skipped.map((s) => ( +

+ {s.name}: {s.reason} +

+ ))} +
+ )} +
+
+ {report ? ( + <> + + + + ) : ( + + )} +
+ + ); +} diff --git a/src/components/interchange/ImportSourceMenu.tsx b/src/components/interchange/ImportSourceMenu.tsx new file mode 100644 index 00000000..34c4b767 --- /dev/null +++ b/src/components/interchange/ImportSourceMenu.tsx @@ -0,0 +1,146 @@ +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowRightLeft, ChevronDown, FileInput, FolderSearch } from 'lucide-react'; +import { listInterchangeSources, showOpenDialog } from '../../lib/api'; +import type { InterchangeSourceInfo, InterchangeSourceRequest } from '../../lib/modInterchange'; +import { AnchoredPopover } from '../common/AnchoredPopover'; +import ImportWizardModal from './ImportWizardModal'; + +interface ImportSourceMenuProps { + onImported: () => void; + /** Compact icon button (toolbar) vs labelled button (empty state). */ + compact?: boolean; +} + +const itemClass = + 'w-full flex items-center gap-3 px-3 py-2 text-left text-sm text-text-primary hover:bg-bg-tertiary rounded-md transition-colors cursor-pointer'; + +/** + * "Import from other mod managers": one entry per mod manager Grimoire can + * read (reported by the main-process registry), plus any manager's exported + * mod-interchange file. + */ +export default function ImportSourceMenu({ onImported, compact = false }: ImportSourceMenuProps) { + const { t } = useTranslation(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const [sources, setSources] = useState(null); + const [active, setActive] = useState<{ request: InterchangeSourceRequest; name: string } | null>( + null + ); + + const toggle = () => { + setOpen((v) => !v); + if (!sources) { + listInterchangeSources() + .then(setSources) + .catch(() => setSources([])); + } + }; + const close = useCallback(() => setOpen(false), []); + + const pickFolder = async (info: InterchangeSourceInfo) => { + close(); + const dir = await showOpenDialog({ + directory: true, + title: t('interchange.pickManagerFolder', { source: info.name }), + }); + if (dir) setActive({ request: { kind: 'manager', id: info.id, location: dir }, name: info.name }); + }; + + const pickBundle = async () => { + close(); + const file = await showOpenDialog({ + title: t('interchange.pickBundle'), + filters: [{ name: 'mod-interchange.json', extensions: ['json'] }], + }); + if (file) setActive({ request: { kind: 'bundle', path: file }, name: t('interchange.exportFile') }); + }; + + return ( +
+ + + +

+ {t('interchange.managersLabel')} +

+ {!sources &&

{t('interchange.detecting')}

} + {sources?.map((info) => ( + + ))} + {sources?.map((info) => ( + + ))} +
+ + + + {active && ( + setActive(null)} + onImported={onImported} + /> + )} +
+ ); +} diff --git a/src/components/interchange/ImportWizardModal.tsx b/src/components/interchange/ImportWizardModal.tsx new file mode 100644 index 00000000..579de88b --- /dev/null +++ b/src/components/interchange/ImportWizardModal.tsx @@ -0,0 +1,573 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react'; +import { + associateUnknownMod, + detectUnknownModFilters, + getModDetails, + importInterchangeSelection, + onInterchangeProgress, + readInterchangeSource, +} from '../../lib/api'; +import type { + InterchangeImportReport, + InterchangeMod, + InterchangePreview, + InterchangeProgress, + InterchangeSourceRequest, +} from '../../lib/modInterchange'; +import { getModThumbnail } from '../../types/gamebanana'; +import { showToast } from '../../stores/toastStore'; +import { Modal } from '../common/Modal'; +import { Button, CheckboxMark, ModalHeader, Tag } from '../common/ui'; +import { Input } from '../common/forms'; + +type Step = 'loading' | 'contents' | 'importing' | 'unrecognized' | 'done'; + +interface ImportWizardModalProps { + source: InterchangeSourceRequest; + sourceName: string; + onClose: () => void; + /** Called once anything changed, so the library reloads. */ + onImported: () => void; +} + +interface Suggestion { + gameBananaId: number; + modName: string; + fileId?: number; + thumbnailUrl?: string; + nsfw?: boolean; + categoryName?: string; + section?: 'Mod' | 'Sound'; +} + +const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err)); + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +/** A GameBanana link or bare id -> submission id and section. */ +function parseReference(input: string): { id: number; section: 'Mod' | 'Sound' } | null { + const text = input.trim(); + const bare = /^(snd-)?([1-9]\d*)$/i.exec(text); + if (bare) return { id: Number(bare[2]), section: bare[1] ? 'Sound' : 'Mod' }; + const link = /gamebanana\.com\/(mods|sounds)\/([1-9]\d*)/i.exec(text); + if (!link) return null; + return { id: Number(link[2]), section: link[1].toLowerCase() === 'sounds' ? 'Sound' : 'Mod' }; +} + +function Check({ + checked, + disabled, + onChange, + label, +}: { + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; + label: string; +}) { + return ( + <> + onChange(e.target.checked)} + /> + + + ); +} + +/** + * Transfer from another mod manager in steps: choose what to bring over + * (mods, profiles, crosshairs; sections the source lacks are shown but + * disabled), watch the import, then optionally identify mods that arrived + * without a GameBanana link. + */ +export default function ImportWizardModal({ + source, + sourceName, + onClose, + onImported, +}: ImportWizardModalProps) { + const { t } = useTranslation(); + const [step, setStep] = useState('loading'); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [modKeys, setModKeys] = useState>(new Set()); + const [profileKeys, setProfileKeys] = useState>(new Set()); + const [crosshairKeys, setCrosshairKeys] = useState>(new Set()); + const [showMods, setShowMods] = useState(false); + const [progress, setProgress] = useState(null); + const [report, setReport] = useState(null); + const [suggestions, setSuggestions] = useState>({}); + const [manual, setManual] = useState>({}); + const [linked, setLinked] = useState>({}); + const [working, setWorking] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const result = await readInterchangeSource(source); + if (cancelled) return; + setPreview(result); + const fresh = result.document.mods.filter((m) => result.status[m.key] === 'new'); + setModKeys(new Set(fresh.map((m) => m.key))); + setCrosshairKeys(new Set(result.document.crosshairs.map((c) => c.key))); + setStep('contents'); + } catch (err) { + if (!cancelled) setError(errorText(err)); + } + })(); + return () => { + cancelled = true; + }; + }, [source]); + + const document = preview?.document ?? null; + const selectable = useMemo( + () => document?.mods.filter((m) => preview?.status[m.key] === 'new') ?? [], + [document, preview] + ); + const unrecognized = useMemo( + () => (report?.results ?? []).filter((r) => r.status === 'imported' && r.local && r.modId), + [report] + ); + + const toggle = (set: Set, key: string, on: boolean) => { + const next = new Set(set); + if (on) next.add(key); + else next.delete(key); + return next; + }; + + const start = async () => { + if (!document) return; + setStep('importing'); + setWorking(true); + const stop = onInterchangeProgress(setProgress); + try { + const result = await importInterchangeSelection(document, { + modKeys: [...modKeys], + profileKeys: [...profileKeys], + crosshairKeys: [...crosshairKeys], + }); + setReport(result); + onImported(); + const locals = result.results.filter((r) => r.status === 'imported' && r.local && r.modId); + setStep(locals.length > 0 ? 'unrecognized' : 'done'); + } catch (err) { + showToast(t('interchange.importFailed', { error: errorText(err) }), { + tone: 'error', + duration: 8000, + dismissable: true, + }); + setStep('contents'); + } finally { + stop(); + setWorking(false); + } + }; + + const analyze = async () => { + setWorking(true); + let found = 0; + for (const result of unrecognized) { + if (!result.modId || linked[result.modId]) continue; + try { + const guess = await detectUnknownModFilters(result.modId); + const match = guess.crcMatch; + if (match.status === 'found' && match.modId && match.modName) { + found++; + setSuggestions((s) => ({ + ...s, + [result.modId!]: { + gameBananaId: match.modId!, + modName: match.modName!, + fileId: match.fileId, + thumbnailUrl: match.thumbnailUrl, + nsfw: match.nsfw, + categoryName: match.categoryName, + section: match.section, + }, + })); + } else { + setSuggestions((s) => ({ ...s, [result.modId!]: 'none' })); + } + } catch { + setSuggestions((s) => ({ ...s, [result.modId!]: 'none' })); + } + } + setWorking(false); + showToast(t('interchange.analyzeResult', { count: found }), { tone: 'info' }); + }; + + const link = async (modId: string, target: Suggestion) => { + setWorking(true); + try { + await associateUnknownMod(modId, { + gameBananaId: target.gameBananaId, + modName: target.modName, + gameBananaFileId: target.fileId, + thumbnailUrl: target.thumbnailUrl, + nsfw: target.nsfw, + categoryName: target.categoryName, + sourceSection: target.section, + }); + setLinked((l) => ({ ...l, [modId]: target.modName })); + onImported(); + } catch (err) { + showToast(errorText(err), { tone: 'error', duration: 8000, dismissable: true }); + } finally { + setWorking(false); + } + }; + + const linkManual = async (modId: string) => { + const reference = parseReference(manual[modId] ?? ''); + if (!reference) { + showToast(t('interchange.invalidReference'), { tone: 'error' }); + return; + } + setWorking(true); + try { + const details = await getModDetails(reference.id, reference.section); + await link(modId, { + gameBananaId: reference.id, + modName: details.name, + thumbnailUrl: getModThumbnail(details) ?? undefined, + nsfw: details.nsfw, + categoryName: details.category?.name, + section: reference.section, + }); + } catch (err) { + showToast(errorText(err), { tone: 'error', duration: 8000, dismissable: true }); + setWorking(false); + } + }; + + const percent = progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + const nothingSelected = modKeys.size === 0 && profileKeys.size === 0 && crosshairKeys.size === 0; + const hasProfiles = (document?.profiles.length ?? 0) > 0; + const hasCrosshairs = (document?.crosshairs.length ?? 0) > 0; + + const section = ( + id: string, + checked: boolean, + onChange: (on: boolean) => void, + title: string, + detail: string, + disabled = false + ) => ( + + ); + + const modRow = (mod: InterchangeMod) => { + const status = preview?.status[mod.key]; + const disabled = status !== 'new'; + const size = mod.files.reduce((sum, f) => sum + (f.size ?? 0), 0); + return ( +
  • + +
  • + ); + }; + + const busy = step === 'loading' || step === 'importing' || working; + + return ( + + + +
    + {step === 'loading' && + (error ? ( +
    + + {error} +
    + ) : ( +
    + + {t('interchange.scanning', { source: sourceName })} +
    + ))} + + {step === 'contents' && document && ( + <> + {document.mods.length === 0 && ( +

    + {t('interchange.emptySource', { source: sourceName })} +

    + )} + {section( + 'interchange-section-mods', + modKeys.size > 0, + (on) => setModKeys(on ? new Set(selectable.map((m) => m.key)) : new Set()), + t('interchange.sectionMods'), + t('interchange.sectionModsDetail', { count: selectable.length, total: document.mods.length }), + selectable.length === 0 + )} + + {showMods && ( +
      + {document.mods.map(modRow)} +
    + )} + + {section( + 'interchange-section-profiles', + profileKeys.size > 0, + (on) => setProfileKeys(on ? new Set(document.profiles.map((p) => p.key)) : new Set()), + t('interchange.sectionProfiles'), + hasProfiles + ? t('interchange.sectionProfilesDetail', { count: document.profiles.length }) + : t('interchange.noProfiles', { source: sourceName }), + !hasProfiles + )} + {hasProfiles && ( +
      + {document.profiles.map((profile) => ( +
    • + +
    • + ))} +
    + )} + + {section( + 'interchange-section-crosshairs', + crosshairKeys.size > 0, + (on) => setCrosshairKeys(on ? new Set(document.crosshairs.map((c) => c.key)) : new Set()), + t('interchange.sectionCrosshairs'), + hasCrosshairs + ? t('interchange.sectionCrosshairsDetail', { count: document.crosshairs.length }) + : t('interchange.noCrosshairs', { source: sourceName }), + !hasCrosshairs + )} + + {document.warnings.length > 0 && ( +
    + + {t('interchange.warnings', { count: document.warnings.length })} + +
      + {document.warnings.map((w) => ( +
    • {w}
    • + ))} +
    +
    + )} + + )} + + {step === 'importing' && ( +
    +
    +
    +
    +

    + {progress + ? t(`interchange.progress.${progress.stage}`, { + current: Math.min(progress.current + 1, progress.total), + total: progress.total, + }) + : t('interchange.progress.starting')} + {progress?.name ? ` · ${progress.name}` : ''} +

    +
    + )} + + {step === 'unrecognized' && ( + <> +
    +

    + {t('interchange.unrecognizedHint', { count: unrecognized.length })} +

    + +
    +
      + {unrecognized.map((result) => { + const modId = result.modId!; + const suggestion = suggestions[modId]; + const done = linked[modId]; + return ( +
    • +
      +

      {result.name}

      + {done && {t('interchange.linkedBadge', { name: done })}} +
      + {!done && suggestion && suggestion !== 'none' && ( +
      + {t('interchange.suggestion', { name: suggestion.modName })} + +
      + )} + {!done && suggestion === 'none' && ( +

      {t('interchange.noMatch')}

      + )} + {!done && ( +
      + setManual((m) => ({ ...m, [modId]: e.target.value }))} + /> + +
      + )} +
    • + ); + })} +
    + + )} + + {step === 'done' && report && ( +
    +
    + + {t('interchange.summary', { + imported: report.results.filter((r) => r.status === 'imported').length, + skipped: report.results.filter((r) => r.status !== 'imported').length, + })} +
    +
      + {report.profiles.map((p) => ( +
    • + {p.updated + ? t('interchange.profileUpdated', { name: p.name, count: p.mods }) + : p.created + ? t('interchange.profileCreated', { name: p.name, count: p.mods }) + : t('interchange.profileFailed', { name: p.name, error: p.reason ?? '' })} + {p.created && p.reason ? ` (${p.reason})` : ''} +
    • + ))} + {report.crosshairs > 0 &&
    • {t('interchange.crosshairsAdded', { count: report.crosshairs })}
    • } + {Object.keys(linked).length > 0 && ( +
    • {t('interchange.linkedCount', { count: Object.keys(linked).length })}
    • + )} + {report.warnings.map((w) => ( +
    • {w}
    • + ))} +
    +
      + {report.results + .filter((r) => r.reason) + .map((r) => ( +
    • + {r.name} + · {r.reason} +
    • + ))} +
    +
    + )} +
    + +
    + {step === 'contents' && ( + + )} + {step === 'unrecognized' && ( + + )} + {(step === 'done' || error) && } +
    + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 00b150cf..400b8024 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,15 @@ import type { Mod, AppSettings, DeleteModsProgress, GlobalModType, UnknownModFilterGuess, UnknownModDetectionProgress, ApplyUnknownModMatchArgs, ApplyUnknownCustomModArgs, AssociateUnknownModArgs, UnknownModFileList, EditLocalModArgs, MergeModsArgs, UnmergeModResult, ExtractMergeSourceResult, AddMergeSourcesResult, MergeSourceReplacement, ReplaceMergeSourcesResult, ImprintAllInstalledResult, ImprintInstalledProgress, ImprintPreflightResult, ImprintDetails, PeekImprintResult, ApplyHeroCardResult, HeroAbilitySlot, AbilitySlot, AbilitySoundParams, ActiveHeroSound, ApplyHeroSoundResult, ActiveHeroColor, ApplyHeroColorResult, ApplyHeroPrismResult, ActiveTrippySkin, ApplyTrippySkinResult, ApplyTrippyVfxResult, TrippySpriteOptions, TrippySpriteResult, TrippyVfxChoice, LockerOverview, LockerCardThumbnail, LockerClearScope, AppearanceSurface } from '../types/mod'; -import type { DmmMigrationRequest, DmmMigrationReport } from './dmmMigration'; +import type { + InterchangeDocument, + InterchangeExportReport, + InterchangeExportSelection, + InterchangeImportReport, + InterchangeImportSelection, + InterchangePreview, + InterchangeProgress, + InterchangeSourceInfo, + InterchangeSourceRequest, +} from './modInterchange'; import type { HeroPortrait, CustomCardSlot, @@ -89,13 +99,31 @@ export async function setSettings(settings: AppSettings): Promise { return window.electronAPI.setSettings(settings); } -// Deadlock Mod Manager migration (adopt DMM's on-disk VPKs; no cloud) -export async function dmmMigrateScan(req: DmmMigrationRequest): Promise { - return window.electronAPI.dmmMigrate.scan(req); +// Mod transfer between mod managers (neutral interchange format; no cloud) +export async function listInterchangeSources(): Promise { + return window.electronAPI.interchange.sources(); +} + +export function onInterchangeProgress(callback: (progress: InterchangeProgress) => void): () => void { + return window.electronAPI.interchange.onProgress(callback); +} + +export async function readInterchangeSource(req: InterchangeSourceRequest): Promise { + return window.electronAPI.interchange.read(req); +} + +export async function importInterchangeSelection( + document: InterchangeDocument, + selection: InterchangeImportSelection +): Promise { + return window.electronAPI.interchange.import({ document, selection }); } -export async function dmmMigrateExecute(req: DmmMigrationRequest): Promise { - return window.electronAPI.dmmMigrate.execute(req); +export async function exportModsForOtherManagers( + destinationDir: string, + selection: InterchangeExportSelection +): Promise { + return window.electronAPI.interchange.export({ destinationDir, selection }); } // Mods diff --git a/src/lib/crosshair.ts b/src/lib/crosshair.ts index a6b1b173..cec12ad6 100644 --- a/src/lib/crosshair.ts +++ b/src/lib/crosshair.ts @@ -306,3 +306,40 @@ export function parseMachineConvarsCrosshair(content: string): Partial): CrosshairSettings | null { + const out: Partial = {}; + const target = out as Record; + let known = false; + for (const [convar, field, kind] of CONVAR_FIELDS) { + const v = convars[convar]; + if (v === undefined) continue; + known = true; + if (kind === 'boolean') { + target[field] = v === 'true' || v === '1'; + } else { + const n = parseFloat(v); + if (Number.isFinite(n)) target[field] = Math.round(n * 100) / 100; + } + } + // Managers with a single on/off pip border (DMM) only send the legacy flag. + const legacyBorder = convars['citadel_crosshair_pip_border']; + if (legacyBorder !== undefined && out.pipOutlineBorder === undefined) { + known = true; + out.pipBorder = legacyBorder === 'true' || legacyBorder === '1'; + } + return known ? normalizeCrosshairSettings(out) : null; +} + +/** The full convar set for settings, keyed by convar name. */ +export function crosshairConvarsFromSettings(raw: Partial): Record { + const convars: Record = {}; + for (const line of generateCrosshairCommands(raw).split('\n')) { + const [name, ...rest] = line.split(' '); + if (name) convars[name] = rest.join(' '); + } + convars['citadel_crosshair_pip_border'] = String(normalizeCrosshairSettings(raw).pipBorder); + return convars; +} diff --git a/src/lib/dmmManifest.test.ts b/src/lib/dmmManifest.test.ts index 8f9d7801..1022ca61 100644 --- a/src/lib/dmmManifest.test.ts +++ b/src/lib/dmmManifest.test.ts @@ -11,11 +11,20 @@ describe('parseDmmManifest', () => { }); it('rejects an unknown future major version', () => { - expect(() => parseDmmManifest(JSON.stringify({ version: 2, mods: {} }))).toThrow( - /Unsupported .dmm.json version: 2/ + expect(() => parseDmmManifest(JSON.stringify({ version: 4, mods: {} }))).toThrow( + /Unsupported .dmm.json version: 4/ ); }); + it('accepts the sharded v2 and v3 manifests current DMM writes', () => { + for (const version of [2, 3]) { + const manifest = parseDmmManifest( + JSON.stringify({ version, mods: { '1': { enabled: true, shard: 2, currentVpks: ['pak01_dir.vpk'] } } }) + ); + expect(manifest.mods?.['1'].shard).toBe(2); + } + }); + it('accepts a missing version and missing mods', () => { expect(parseDmmManifest('{}')).toEqual({}); }); diff --git a/src/lib/dmmManifest.ts b/src/lib/dmmManifest.ts index b7b1a77b..233bde12 100644 --- a/src/lib/dmmManifest.ts +++ b/src/lib/dmmManifest.ts @@ -16,28 +16,36 @@ * This module is intentionally Electron-free (pure) so it is unit-testable and * can be called from either process. * - * On-disk shape (serde `rename_all = "camelCase"`, manifest version 1): + * On-disk shape (serde `rename_all = "camelCase"`): * { - * "version": 1, + * "version": 3, * "mods": { - * "": { + * "": { // "123", "snd-123" or "local-" * "enabled": true, * "order": 0, // number | null (load priority) + * "shard": 1, // v2+: enabled VPKs live in addons * "currentVpks": ["pak01_dir.vpk"],// live slot names (enabled mods) * "disabledVpks": [], // "_.vpk" (disabled) * "originalVpkNames": ["cool.vpk"] // source archive VPK names * } * } * } + * + * Versions: v1 has no `shard` (everything lives in the profile folder). v2 + * added `shard` for profiles with more than 99 enabled mods: shard 1 is the + * profile folder, shard N >= 2 is `citadel/addons/`. v3 + * only changed how DMM repairs crashed writes; the shape is the same as v2. */ -/** Manifest major version this adapter understands. DMM normalizes a missing - * or 0 version to 1; anything higher is a format we don't model. */ -const SUPPORTED_DMM_MANIFEST_VERSION = 1; +/** Newest manifest major version this adapter understands. Older versions + * are read as-is (a missing shard means shard 1). */ +export const SUPPORTED_DMM_MANIFEST_VERSION = 3; export interface DmmManifestEntry { enabled?: boolean; order?: number | null; + /** 1-based shard the enabled VPKs live in (v2+); missing means 1. */ + shard?: number; currentVpks?: string[]; disabledVpks?: string[]; originalVpkNames?: string[]; @@ -69,7 +77,9 @@ export function parseDmmManifest(input: string): DmmManifest { } // DMM normalizes missing/0 -> 1, so only a higher major is unknown to us. if (typeof o.version === 'number' && o.version > SUPPORTED_DMM_MANIFEST_VERSION) { - throw new Error(`Unsupported .dmm.json version: ${o.version} (this build understands v1)`); + throw new Error( + `Unsupported .dmm.json version: ${o.version} (this build understands up to v${SUPPORTED_DMM_MANIFEST_VERSION})` + ); } if ( o.mods !== undefined && diff --git a/src/lib/dmmMigration.test.ts b/src/lib/dmmMigration.test.ts index 4d64bccc..32082592 100644 --- a/src/lib/dmmMigration.test.ts +++ b/src/lib/dmmMigration.test.ts @@ -6,6 +6,9 @@ import { composeDmmAdoptionPlan, planToPreview, submissionIdFromVpkName, + parseDmmModId, + dmmIdFromVpkName, + interchangeKeyForEntry, } from './dmmMigration'; import { parseDmmState, selectDmmProfile, indexDmmStateBySubmission } from './dmmState'; import type { DmmManifest } from './dmmManifest'; @@ -373,3 +376,79 @@ describe('planToPreview', () => { expect(preview.find((p) => p.submissionId === 777)!.hasFileId).toBe(false); }); }); + +describe('DMM 2.x ids and shards', () => { + const UUID = '0f8fad5b-d9cb-469f-a165-70867728950e'; + + it('parses every DMM id shape', () => { + expect(parseDmmModId('123')).toEqual({ kind: 'mod', dmmId: '123', submissionId: 123 }); + expect(parseDmmModId('snd-42')).toEqual({ kind: 'sound', dmmId: 'snd-42', submissionId: 42 }); + expect(parseDmmModId(`local-${UUID.toUpperCase()}`)).toMatchObject({ kind: 'local', localId: UUID }); + expect(parseDmmModId('local-thing')).toBeNull(); + expect(parseDmmModId('0123')).toBeNull(); + expect(dmmIdFromVpkName(`local-${UUID}_cool_dir.vpk`)).toBe(`local-${UUID}`); + expect(dmmIdFromVpkName('snd-42_x.vpk')).toBe('snd-42'); + expect(dmmIdFromVpkName('pak01_dir.vpk')).toBeNull(); + }); + + it('plans sound and local mods and keeps same-named slots in different shards apart', () => { + const manifest: DmmManifest = { + version: 3, + mods: { + '1': { enabled: true, order: 0, shard: 1, currentVpks: ['pak01_dir.vpk'] }, + '2': { enabled: true, order: 1, shard: 2, currentVpks: ['pak01_dir.vpk'] }, + 'snd-3': { enabled: false, order: 2, disabledVpks: ['snd-3_voice.vpk'] }, + [`local-${UUID}`]: { enabled: true, order: 3, shard: 1, currentVpks: ['pak02_dir.vpk'] }, + }, + }; + const plan = planDmmAdoption(manifest, null, { + stateByDmmId: new Map([ + [`local-${UUID}`, { remoteId: `local-${UUID}`, submissionId: NaN, name: 'My Local', author: 'me' }], + ]), + }); + expect(plan.entries.map((e) => [e.dmmId, e.kind, e.shard])).toEqual([ + ['1', 'mod', 1], + ['2', 'mod', 2], + ['snd-3', 'sound', 1], + [`local-${UUID}`, 'local', 1], + ]); + const local = plan.entries[3]; + expect(local).toMatchObject({ submissionId: 0, localId: UUID, modName: 'My Local', author: 'me' }); + expect(interchangeKeyForEntry(local)).toBe(`local:${UUID}`); + expect(interchangeKeyForEntry(plan.entries[2])).toBe('gamebanana:sound:3'); + // The file-id warning only counts GameBanana entries. + expect(plan.warnings.some((w) => w.startsWith('3 mod(s)'))).toBe(true); + }); + + it('falls back to the mod store names when no addon file is recorded', () => { + const plan = planDmmAdoption({ version: 3, mods: { '9': { enabled: true, order: 0 } } }, null, { + stateByDmmId: new Map([['9', { remoteId: '9', submissionId: 9, selectedVpkNames: ['blue.vpk'] }]]), + }); + expect(plan.entries[0].vpkFiles).toEqual(['blue.vpk']); + }); + + it('reads DMM 2.x gamebanana-file download urls', () => { + const state = parseDmmState( + JSON.stringify({ + 'local-config': JSON.stringify({ + state: { + localMods: [ + { + remoteId: '5', + selectedDownloads: [{ url: 'gamebanana-file://5/777', name: 'x.zip' }], + images: ['data:image/svg+xml;utf8,x', 'https://img/x.png'], + installedFileTree: { files: [{ name: 'a.vpk', is_selected: true }, { name: 'b.vpk', is_selected: false }] }, + }, + ], + profiles: {}, + }, + }), + }) + ); + expect(state.localMods[0]).toMatchObject({ + fileId: 777, + thumbnailUrl: 'https://img/x.png', + selectedVpkNames: ['a.vpk'], + }); + }); +}); diff --git a/src/lib/dmmMigration.ts b/src/lib/dmmMigration.ts index 3544e9d7..39029f27 100644 --- a/src/lib/dmmMigration.ts +++ b/src/lib/dmmMigration.ts @@ -1,53 +1,93 @@ /** * DMM -> Grimoire migration planner (pure). Combines DMM's two local sources - * into an ordered list of "adopt this VPK with this metadata" instructions that - * the Electron orchestration (electron/main/services/dmmMigration.ts) executes - * by copying the file into Grimoire's addons layout and writing the metadata - * sidecar. No re-download, no DMM cloud. + * into an ordered list of "adopt this VPK with this metadata" instructions. The + * DMM reader (electron/main/services/modInterchange/dmmReader.ts) turns the + * plan into a manager-neutral interchange document, which the interchange + * importer then adopts. No re-download, no DMM cloud. * * Authority split: * - `.dmm.json` (DmmManifest) is the authority for WHICH VPK files are on disk, - * their enabled state, and load order. It is the addons-folder manifest. - * - `state.json` (DmmState, indexed by submission id) enriches each mod with - * the GameBanana file id, name, source filename, and thumbnail. + * their enabled state, shard, and load order. It is the addons-folder manifest. + * - `state.json` (DmmState, indexed by DMM id) enriches each mod with the + * GameBanana file id, name, author, source filename, and thumbnail. * * When `.dmm.json` is absent we synthesize an equivalent manifest from a * state.json profile (manifestFromDmmProfile) so the same planner drives both. * + * DMM ids come in three shapes: `123` (GameBanana mod), `snd-123` (GameBanana + * sound) and `local-` (added from disk). All three are planned. + * * Hero is deliberately NOT carried into the plan: DMM stores a lowercase * codename ("vyper") that does not map 1:1 to Grimoire's canonical hero names, * and Grimoire's enrichMod infers the hero from the adopted VPK's file tree * anyway. That inference is the "auto recognize" behavior we want. */ -import { parseDmmManifest, type DmmManifest } from './dmmManifest'; +import { parseDmmManifest, type DmmManifest, type DmmManifestEntry } from './dmmManifest'; import { parseDmmState, selectDmmProfile, indexDmmStateBySubmission, + indexDmmStateByRemoteId, type DmmStateMod, type DmmStateProfile, } from './dmmState'; +/** DMM's mod ids: `123` (GameBanana mod), `snd-123` (GameBanana sound) or + * `local-` (a mod the user added from disk). */ +export type DmmModIdentity = + | { kind: 'mod' | 'sound'; dmmId: string; submissionId: number } + | { kind: 'local'; dmmId: string; localId: string }; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function parseDmmModId(key: string): DmmModIdentity | null { + const gb = key.match(/^(snd-)?([1-9]\d*)$/); + if (gb) { + const submissionId = Number(gb[2]); + if (!Number.isSafeInteger(submissionId)) return null; + return { kind: gb[1] ? 'sound' : 'mod', dmmId: key, submissionId }; + } + if (key.startsWith('local-') && UUID_RE.test(key.slice(6))) { + return { kind: 'local', dmmId: key, localId: key.slice(6).toLowerCase() }; + } + return null; +} + export interface DmmAdoptionEntry { - /** GameBanana submission id. */ + /** DMM's own id for the mod (`123`, `snd-123`, `local-`). */ + dmmId: string; + kind: DmmModIdentity['kind']; + /** GameBanana submission id; 0 for local mods, which have none. */ submissionId: number; + /** DMM's UUID for local mods. */ + localId?: string; + /** DMM shard the enabled VPKs live in (1 = the profile folder). */ + shard: number; /** GameBanana file id when recoverable from state.json; else undefined * (Grimoire treats undefined as "unknown version" for update detection). */ fileId?: number; modName?: string; + author?: string; + description?: string; categoryName?: string; thumbnailUrl?: string; /** Label fallback: stem of the source archive/download filename. */ sourceFileName?: string; + /** Original VPK names the user picked in DMM (mod-store fallback). */ + selectedVpkNames?: string[]; enabled: boolean; /** Grimoire load-order priority (lower loads first). */ priority: number; /** On-disk VPK basenames belonging to this mod, ALL of which are adopted and - * tagged with this submission id (a DMM mod may ship several VPKs). Contested + * tagged with this mod's identity (a DMM mod may ship several VPKs). Contested * files (also listed by another mod through stale DMM bookkeeping) are removed * here and awarded to a single owner, so two mods never fight over one slot. */ vpkFiles: string[]; + /** `vpkFiles` are names inside the downloaded archive, not files DMM + * installed: they only mean something in DMM's download folder. A + * `pak01_dir.vpk` there says nothing about the live pak01 slot. */ + storeOnly?: boolean; } export interface DmmAdoptionPlan { @@ -65,6 +105,10 @@ export interface DmmAdoptionOptions { * data records no filename for a mod (some installs leave installedVpks * empty for the actively-loaded `_*.vpk` files in addons). */ extraVpkBySubmission?: Map; + /** Same fallback keyed by DMM id, which also covers sound and local mods. */ + extraVpkByDmmId?: Map; + /** state.json lookup by DMM id (covers sound and local mods). */ + stateByDmmId?: Map; } /** Recover a GameBanana submission id from a DMM-style on-disk VPK name like @@ -76,6 +120,14 @@ export function submissionIdFromVpkName(fileName: string): number | null { return Number.isInteger(id) && id > 0 ? id : null; } +/** Recover DMM's mod id from a parked `_.vpk` file name, for + * every id shape (`123_`, `snd-123_`, `local-_`). Null otherwise. */ +export function dmmIdFromVpkName(fileName: string): string | null { + const underscore = fileName.indexOf('_'); + if (underscore < 1 || !/\.vpk$/i.test(fileName)) return null; + return parseDmmModId(fileName.slice(0, underscore))?.dmmId ?? null; +} + function stripArchiveExt(name: string): string { return name.replace(/\.(zip|7z|rar|tar|gz|vpk)$/i, ''); } @@ -93,61 +145,121 @@ export function planDmmAdoption( const manifestEntries = Object.entries(manifest.mods ?? {}); const isStr = (v: unknown): v is string => typeof v === 'string' && !!v; - // Compute the trailing priority for order-less mods over kept (numeric) keys - // only, so a skipped local mod doesn't reserve an empty slot. + const stateInfo = (identity: DmmModIdentity): DmmStateMod | undefined => + options.stateByDmmId?.get(identity.dmmId) ?? + (identity.kind === 'mod' ? stateIndex?.get(identity.submissionId) : undefined); + // DMM writes `order: null` into .dmm.json; the load order it shows lives in + // state.json's installOrder. The manifest's own order is the fallback. + const orderOf = (key: string, e: DmmManifestEntry | undefined): number | undefined => { + const identity = parseDmmModId(key); + const installOrder = identity ? stateInfo(identity)?.installOrder : undefined; + if (typeof installOrder === 'number' && Number.isFinite(installOrder)) return installOrder; + return typeof e?.order === 'number' && Number.isFinite(e.order) ? e.order : undefined; + }; + + // Order-less mods trail the ordered ones over kept (valid) keys only, so a + // skipped mod doesn't reserve an empty slot. Object key order means nothing + // (numeric ids sort first), so enabled mods follow the pakNN slots DMM laid + // them out in, then disabled ones. let maxOrder = -1; + const orderless: Array<[string, DmmManifestEntry | undefined]> = []; for (const [key, e] of manifestEntries) { - const id = Number(key); - if (Number.isInteger(id) && id > 0 && e && typeof e.order === 'number') { - maxOrder = Math.max(maxOrder, e.order); - } + if (!parseDmmModId(key)) continue; + const order = orderOf(key, e); + if (order !== undefined) maxOrder = Math.max(maxOrder, order); + else orderless.push([key, e]); } - let trailing = maxOrder + 1; + const slotOf = (e: DmmManifestEntry | undefined): number => { + if (e?.enabled !== true) return Number.MAX_SAFE_INTEGER; + const pak = (e.currentVpks ?? []) + .map((name) => /^pak(\d+)_dir\.vpk$/i.exec(name)?.[1]) + .filter((n): n is string => n !== undefined) + .map(Number); + const shard = typeof e.shard === 'number' && e.shard >= 1 ? e.shard : 1; + return pak.length > 0 ? shard * 1000 + Math.min(...pak) : Number.MAX_SAFE_INTEGER - 1; + }; + const trailingOrder = new Map( + orderless + .map(([key, e], index) => ({ key, slot: slotOf(e), index })) + .sort((a, b) => a.slot - b.slot || a.index - b.index) + .map(({ key }, index) => [key, maxOrder + 1 + index]) + ); // First pass: resolve each mod's full set of live on-disk VPK files. A DMM mod // can ship several VPKs (its `currentVpks`/`disabledVpks` list has >1 entry), // and ALL of them belong to that one mod, so the whole set is carried (not just - // the first) to be adopted together under one submission id. + // the first) to be adopted together under one identity. interface Draft { + identity: DmmModIdentity; submissionId: number; + shard: number; enabled: boolean; priority: number; files: string[]; + storeOnly: boolean; info?: DmmStateMod; sourceFileName?: string; } const drafts: Draft[] = []; for (const [key, rawEntry] of manifestEntries) { - const submissionId = Number(key); - if (!Number.isInteger(submissionId) || submissionId <= 0) { + const identity = parseDmmModId(key); + if (!identity) { warnings.push(`Skipped non-GameBanana mod: ${key}`); continue; } + const submissionId = identity.kind === 'local' ? 0 : identity.submissionId; const e = rawEntry ?? {}; const enabled = e.enabled === true; - const priority = typeof e.order === 'number' ? e.order : trailing++; + const priority = orderOf(key, e) ?? trailingOrder.get(key) ?? maxOrder + 1; + const shard = + typeof e.shard === 'number' && Number.isInteger(e.shard) && e.shard >= 1 ? e.shard : 1; // The mod's live files: its `currentVpks` live pakNN slots when enabled, its // `disabledVpks` parked "_.vpk" names when disabled. Fall back to // the cross list if DMM left the expected one empty (inconsistent state), then - // to the disk-scanned `_*.vpk` files for mods DMM recorded no - // filename for. + // to the disk-scanned `_*.vpk` files for mods DMM recorded no filename for. const current = (e.currentVpks ?? []).filter(isStr); const disabled = (e.disabledVpks ?? []).filter(isStr); let files = enabled ? current : disabled; if (files.length === 0) files = enabled ? disabled : current; - if (files.length === 0) files = (options.extraVpkBySubmission?.get(submissionId) ?? []).slice(); + if (files.length === 0) { + files = ( + options.extraVpkByDmmId?.get(identity.dmmId) ?? + (identity.kind === 'mod' ? options.extraVpkBySubmission?.get(submissionId) : undefined) ?? + [] + ).slice(); + } + + const info = stateInfo(identity); + // Last resort: DMM's mod store keeps every downloaded VPK under its + // original name, so a mod whose addon files are gone can still come + // across. These are archive names (often `pakNN_dir.vpk`), so the reader + // looks them up in the store folder only, never in the addon slots. + let storeOnly = false; + if (files.length === 0 && info?.selectedVpkNames?.length) { + files = info.selectedVpkNames.slice(); + storeOnly = true; + } if (files.length === 0) { - warnings.push(`Skipped mod ${submissionId}: no VPK filename recorded on disk`); + warnings.push(`Skipped mod ${key}: no VPK filename recorded on disk`); continue; } - const info = stateIndex?.get(submissionId); const sourceFileNameRaw = info?.downloadFileName ?? (e.originalVpkNames ?? [])[0]; const sourceFileName = sourceFileNameRaw ? stripArchiveExt(sourceFileNameRaw) : undefined; - drafts.push({ submissionId, enabled, priority, files, info, sourceFileName: sourceFileName || undefined }); + drafts.push({ + identity, + submissionId, + shard, + enabled, + priority, + files, + storeOnly, + info, + sourceFileName: sourceFileName || undefined, + }); } // Resolve contested files: the same on-disk VPK is sometimes listed by more @@ -156,42 +268,61 @@ export function planDmmAdoption( // file to a single owner so two mods never get tagged onto one VPK. A single- // VPK mod almost always reflects the slot's current truth over a multi-VPK // pack's stale claim, so rank by fewest files first, then load order, then id - // for determinism. + // for determinism. Enabled files are keyed per shard: every shard has its + // own pak01. Store-only names claim no slot at all: they live in the mod's + // own download folder. const ranked = [...drafts].sort( - (a, b) => a.files.length - b.files.length || a.priority - b.priority || a.submissionId - b.submissionId + (a, b) => + a.files.length - b.files.length || + a.priority - b.priority || + a.submissionId - b.submissionId || + a.identity.dmmId.localeCompare(b.identity.dmmId) ); - const owner = new Map(); + const slotKey = (d: Draft, f: string) => + d.storeOnly + ? `store:${d.identity.dmmId}:${f.toLowerCase()}` + : `${d.enabled ? d.shard : 0}:${f.toLowerCase()}`; + const owner = new Map(); for (const d of ranked) { for (const f of d.files) { - const k = f.toLowerCase(); - if (!owner.has(k)) owner.set(k, d.submissionId); + const k = slotKey(d, f); + if (!owner.has(k)) owner.set(k, d.identity.dmmId); } } const entries: DmmAdoptionEntry[] = []; for (const d of drafts) { - const vpkFiles = d.files.filter((f) => owner.get(f.toLowerCase()) === d.submissionId); + const vpkFiles = d.files.filter((f) => owner.get(slotKey(d, f)) === d.identity.dmmId); if (vpkFiles.length === 0) { warnings.push( - `Skipped mod ${d.submissionId}: its VPK(s) are already claimed by another mod (stale DMM data)` + `Skipped mod ${d.identity.dmmId}: its VPK(s) are already claimed by another mod (stale DMM data)` ); continue; } - if (d.info?.fileId !== undefined) resolvedFileIdCount++; + const isLocal = d.identity.kind === 'local'; + if (!isLocal && d.info?.fileId !== undefined) resolvedFileIdCount++; entries.push({ + dmmId: d.identity.dmmId, + kind: d.identity.kind, submissionId: d.submissionId, - fileId: d.info?.fileId, + localId: d.identity.kind === 'local' ? d.identity.localId : undefined, + shard: d.shard, + fileId: isLocal ? undefined : d.info?.fileId, modName: d.info?.name, + author: d.info?.author, + description: d.info?.description, categoryName: d.info?.category, thumbnailUrl: d.info?.thumbnailUrl, sourceFileName: d.sourceFileName, + selectedVpkNames: d.info?.selectedVpkNames, enabled: d.enabled, priority: d.priority, vpkFiles, + ...(d.storeOnly ? { storeOnly: true } : {}), }); } - const unresolved = entries.length - resolvedFileIdCount; + const unresolved = entries.filter((e) => e.kind !== 'local').length - resolvedFileIdCount; if (unresolved > 0) { warnings.push( `${unresolved} mod(s) imported without a pinned GameBanana file id ` + @@ -213,7 +344,7 @@ export function planDmmAdoption( export function manifestFromDmmProfile(profile: DmmStateProfile): DmmManifest { const mods: NonNullable = {}; for (const mod of profile.mods) { - if (!Number.isInteger(mod.submissionId) || mod.submissionId <= 0) continue; + if (!parseDmmModId(mod.remoteId)) continue; const enabled = profile.enabledMods[mod.remoteId] === true; mods[mod.remoteId] = { enabled, @@ -240,8 +371,8 @@ export type DmmEnrichment = 'state.json' | 'manifest-only'; /** * Full tiered decision logic, end to end, from raw file contents. Pure (no * file I/O), so the entire scan/preview path is unit-testable. The Electron - * orchestration reads the two files off disk and calls this; everything after - * is plain mapping. + * reader reads the two files off disk and calls this; everything after is + * plain mapping. * * Tiers, in order of preference: * - `.dmm.json` present -> it is the on-disk authority; state.json (if any) @@ -257,9 +388,11 @@ export function composeDmmAdoptionPlan( profileId?: string; profileName?: string; extraVpkBySubmission?: Map; + extraVpkByDmmId?: Map; } = {} ): { plan: DmmAdoptionPlan; enrichment: DmmEnrichment } { let stateIndex: Map | null = null; + let stateByDmmId: Map | undefined; let stateProfile: DmmStateProfile | null = null; let stateProfileName: string | undefined; @@ -268,10 +401,12 @@ export function composeDmmAdoptionPlan( const state = parseDmmState(stateJson); stateProfile = selectDmmProfile(state, opts.profileId); stateIndex = indexDmmStateBySubmission(state, stateProfile); + stateByDmmId = indexDmmStateByRemoteId(state, stateProfile); stateProfileName = stateProfile?.name; } catch { // Unreadable state.json: degrade to manifest-only enrichment. stateIndex = null; + stateByDmmId = undefined; stateProfile = null; } } @@ -280,6 +415,8 @@ export function composeDmmAdoptionPlan( const planOpts: DmmAdoptionOptions = { profileName, extraVpkBySubmission: opts.extraVpkBySubmission, + extraVpkByDmmId: opts.extraVpkByDmmId, + stateByDmmId, }; if (manifestJson) { @@ -297,9 +434,9 @@ export function composeDmmAdoptionPlan( throw new Error('No DMM data: neither a .dmm.json manifest nor a usable state.json profile.'); } -// --- Wire types shared by main, preload, and renderer (kept pure here) --- +// --- Wire types shared by main and tests (kept pure here) --- -/** Request shape for the scan/migrate IPC. `deadlockPath` is resolved in the +/** Request shape for a DMM migration. `deadlockPath` is resolved in the * main process from settings, so it is not part of the request. */ export interface DmmMigrationRequest { /** Folder holding DMM's VPKs (+ optionally `.dmm.json`). Omit for the common @@ -327,6 +464,9 @@ export interface DmmMigrationRequest { export type DmmMigrationMode = 'in-place' | 'copy'; export interface DmmMigrationPreviewEntry { + /** Interchange key of the entry. */ + key: string; + /** GameBanana submission id; 0 for local mods. */ submissionId: number; modName?: string; enabled: boolean; @@ -336,6 +476,8 @@ export interface DmmMigrationPreviewEntry { } export interface DmmMigrationAdopted { + key: string; + /** GameBanana submission id; 0 for local mods. */ submissionId: number; fileId?: number; modName?: string; @@ -346,6 +488,8 @@ export interface DmmMigrationAdopted { } export interface DmmMigrationSkip { + key: string; + /** GameBanana submission id; 0 for local mods. */ submissionId: number; reason: string; } @@ -363,9 +507,16 @@ export interface DmmMigrationReport { warnings: string[]; } +/** Interchange key for a planned entry. */ +export function interchangeKeyForEntry(entry: Pick): string { + if (entry.kind === 'local') return `local:${entry.localId}`; + return `gamebanana:${entry.kind}:${entry.submissionId}`; +} + /** Project a plan into the preview rows shown before migrating. */ export function planToPreview(plan: DmmAdoptionPlan): DmmMigrationPreviewEntry[] { return plan.entries.map((e) => ({ + key: interchangeKeyForEntry(e), submissionId: e.submissionId, modName: e.modName, enabled: e.enabled, diff --git a/src/lib/dmmState.ts b/src/lib/dmmState.ts index bb9f48ef..8845eb69 100644 --- a/src/lib/dmmState.ts +++ b/src/lib/dmmState.ts @@ -30,6 +30,8 @@ export interface DmmStateMod { /** Parsed numeric submission id, or NaN if non-numeric (local mod). */ submissionId: number; name?: string; + author?: string; + description?: string; category?: string; /** DMM's hero codename (heroOverride > detectedHero > hero). Informational * only: we let Grimoire's VPK-tree inference assign the canonical hero. */ @@ -43,6 +45,9 @@ export interface DmmStateMod { installOrder?: number; /** On-disk VPK basenames DMM recorded for this mod, when present. */ installedVpks?: string[]; + /** Original VPK names the user selected (DMM's installedFileTree), used to + * pick the right files from DMM's mod store when the addon files are gone. */ + selectedVpkNames?: string[]; } export interface DmmStateProfile { @@ -64,14 +69,17 @@ export interface DmmState { const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); -/** Recover the GameBanana file id from a DMM download URL. DMM stores only the - * `_sDownloadUrl` (`https://gamebanana.com/dl/`), so the trailing - * `/dl/` integer is the file `_idRow`. Returns undefined if not present. */ +/** Recover the GameBanana file id from a DMM download URL. DMM 1.x stores the + * `_sDownloadUrl` (`https://gamebanana.com/dl/`), DMM 2.x its own + * `gamebanana-file:///`; either way the trailing integer is + * the file `_idRow`. Returns undefined if not present. */ export function fileIdFromDownloadUrl(url: unknown): number | undefined { if (typeof url !== 'string') return undefined; // Anchor to the GameBanana host so an unrelated mirror/CDN link that merely - // contains a `/dl/` segment can't masquerade as a file id. - const m = url.match(/gamebanana\.com\/dl\/(\d+)/i); + // contains a `/dl/` segment can't masquerade as a file id. DMM 2.x stores + // its own `gamebanana-file:///` scheme instead. + const m = + url.match(/^gamebanana-file:\/\/[^/]+\/(\d+)$/i) ?? url.match(/gamebanana\.com\/dl\/(\d+)/i); if (!m) return undefined; const id = Number(m[1]); return Number.isInteger(id) && id > 0 ? id : undefined; @@ -105,8 +113,20 @@ function normalizeMod(raw: unknown): DmmStateMod | null { const idDownload = downloadObjs.find((d) => fileIdFromDownloadUrl(d.url) !== undefined); const labelDownload = idDownload ?? downloadObjs[0]; + // Local DMM mods keep an inline data: URL preview; only real URLs are + // useful to Grimoire (the sidecar stores a URL, not image bytes). const images = Array.isArray(raw.images) ? raw.images : []; - const thumbnailUrl = images.find((i): i is string => typeof i === 'string' && !!i); + const thumbnailUrl = images.find( + (i): i is string => typeof i === 'string' && /^https?:\/\//i.test(i) + ); + + const tree = isObject(raw.installedFileTree) ? raw.installedFileTree : null; + const selectedVpkNames = Array.isArray(tree?.files) + ? (tree.files as unknown[]) + .filter(isObject) + .filter((f) => f.is_selected !== false && typeof f.name === 'string') + .map((f) => f.name as string) + : undefined; const installedVpks = Array.isArray(raw.installedVpks) ? raw.installedVpks.filter((v): v is string => typeof v === 'string') @@ -116,6 +136,9 @@ function normalizeMod(raw: unknown): DmmStateMod | null { remoteId, submissionId: Number(remoteId), name: typeof raw.name === 'string' ? raw.name : undefined, + author: typeof raw.author === 'string' && raw.author.trim() ? raw.author : undefined, + description: + typeof raw.description === 'string' && raw.description.trim() ? raw.description : undefined, category: typeof raw.category === 'string' ? raw.category : undefined, hero: pickHero(raw), thumbnailUrl, @@ -124,6 +147,7 @@ function normalizeMod(raw: unknown): DmmStateMod | null { labelDownload && typeof labelDownload.name === 'string' ? labelDownload.name : undefined, installOrder: typeof raw.installOrder === 'number' ? raw.installOrder : undefined, installedVpks, + selectedVpkNames: selectedVpkNames && selectedVpkNames.length > 0 ? selectedVpkNames : undefined, }; } @@ -244,3 +268,22 @@ export function indexDmmStateBySubmission( } return index; } + +/** Build a DMM-id -> DmmStateMod lookup (`123`, `snd-123`, `local-`). + * Unlike indexDmmStateBySubmission this keeps sound and local mods, which + * have no numeric submission id of their own. Profile entries win. */ +export function indexDmmStateByRemoteId( + state: DmmState, + profile?: DmmStateProfile | null +): Map { + const index = new Map(); + for (const list of [profile?.mods ?? [], state.localMods]) { + for (const mod of list) { + const existing = index.get(mod.remoteId); + if (!existing || (existing.fileId === undefined && mod.fileId !== undefined)) { + index.set(mod.remoteId, mod); + } + } + } + return index; +} diff --git a/src/lib/modInterchange.test.ts b/src/lib/modInterchange.test.ts new file mode 100644 index 00000000..55c45ba1 --- /dev/null +++ b/src/lib/modInterchange.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { isPlainVpkName, parseInterchangeDocument } from './modInterchange'; + +const doc = (mods: unknown[], extra: Record = {}) => + JSON.stringify({ format: 'deadlock-mod-interchange', version: 1, mods, ...extra }); + +describe('parseInterchangeDocument', () => { + it('keeps good entries and reports broken ones instead of failing', () => { + const parsed = parseInterchangeDocument( + doc([ + { + key: 'gamebanana:mod:1', + name: 'A', + enabled: true, + order: 3, + origin: { provider: 'gamebanana', submissionType: 'mod', submissionId: 1, fileId: 9 }, + files: [{ name: 'a_dir.vpk', path: 'files/a/a_dir.vpk' }, { name: 'broken' }], + futureField: 'ignored', + }, + { key: 'x', name: 'B', origin: { provider: 'somewhere-else' } }, + { key: 'gamebanana:mod:1', name: 'dup', origin: { provider: 'local' } }, + { name: 'no key', origin: { provider: 'local' } }, + ]) + ); + expect(parsed.mods).toHaveLength(1); + expect(parsed.mods[0].origin).toEqual({ + provider: 'gamebanana', + submissionType: 'mod', + submissionId: '1', + fileId: 9, + fileName: undefined, + }); + expect(parsed.mods[0].files).toHaveLength(1); + expect(parsed.warnings).toHaveLength(3); + expect(parsed.source.manager).toBe('unknown'); + }); + + it('rejects other formats and future major versions', () => { + expect(() => parseInterchangeDocument('{"format":"x","version":1}')).toThrow(/Not a mod interchange/); + expect(() => parseInterchangeDocument(doc([], { version: 2 }))).toThrow(/Unsupported interchange version/); + expect(() => parseInterchangeDocument('nope')).toThrow(/not valid JSON/); + }); +}); + +describe('isPlainVpkName', () => { + it('accepts only a single vpk file name', () => { + expect(isPlainVpkName('pak01_dir.vpk')).toBe(true); + expect(isPlainVpkName('../x.vpk')).toBe(false); + expect(isPlainVpkName('a\\b.vpk')).toBe(false); + expect(isPlainVpkName('readme.txt')).toBe(false); + }); +}); diff --git a/src/lib/modInterchange.ts b/src/lib/modInterchange.ts new file mode 100644 index 00000000..e389f61a --- /dev/null +++ b/src/lib/modInterchange.ts @@ -0,0 +1,429 @@ +/** + * The manager-neutral "Deadlock Mod Interchange" document (docs/mod-interchange.md). + * + * Grimoire and Deadlock Mod Manager (DMM) both speak it, so a transfer is + * always reader -> interchange -> importer, and a third mod manager only has + * to implement those same adapters to join. This module is the pure part + * (types + lenient parsing), shared by the main process and the renderer. + */ + +export const INTERCHANGE_FORMAT = 'deadlock-mod-interchange'; +export const INTERCHANGE_VERSION = 1; +export const INTERCHANGE_MANIFEST = 'mod-interchange.json'; + +export interface InterchangeFile { + /** Plain file name, `*.vpk`. */ + name: string; + /** Relative to the manifest in a bundle; absolute in a live reader's output. */ + path: string; + sha256?: string; + size?: number; + /** false = an alternative variant that is kept but not loaded. */ + selected?: boolean; +} + +export type InterchangeOrigin = + | { + provider: 'gamebanana'; + submissionType: 'mod' | 'sound'; + submissionId: string; + fileId?: number; + fileName?: string; + } + | { provider: 'local'; localId?: string }; + +export interface InterchangeMod { + key: string; + name: string; + enabled: boolean; + order: number; + origin: InterchangeOrigin; + author?: string | null; + description?: string | null; + category?: string | null; + hero?: string | null; + thumbnailUrl?: string | null; + link?: string | null; + nsfw?: boolean | null; + files: InterchangeFile[]; + extensions?: Record>; +} + +export interface InterchangeProfileMod { + modKey: string; + enabled: boolean; + order: number; +} + +/** Optional section: only managers with profiles produce it. */ +export interface InterchangeProfile { + key: string; + name: string; + active: boolean; + description?: string | null; + mods: InterchangeProfileMod[]; + crosshairKey?: string | null; + autoexec?: string[] | null; +} + +/** Optional section: a crosshair as the game's own `citadel_crosshair_*` + * convars, so no manager-specific model leaks into the format. */ +export interface InterchangeCrosshair { + key: string; + name: string; + active: boolean; + convars: Record; +} + +export type InterchangeSection = 'mods' | 'profiles' | 'crosshairs'; + +export interface InterchangeDocument { + format: typeof INTERCHANGE_FORMAT; + version: number; + createdAt?: string; + source: { manager: string; managerVersion?: string; profileName?: string }; + /** Sections the producer included. */ + contents: InterchangeSection[]; + mods: InterchangeMod[]; + profiles: InterchangeProfile[]; + crosshairs: InterchangeCrosshair[]; + warnings: string[]; +} + +const isObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const optString = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() ? v : undefined; + +export function newInterchangeDocument( + source: InterchangeDocument['source'] +): InterchangeDocument { + return { + format: INTERCHANGE_FORMAT, + version: INTERCHANGE_VERSION, + createdAt: new Date().toISOString(), + source, + contents: ['mods'], + mods: [], + profiles: [], + crosshairs: [], + warnings: [], + }; +} + +/** Mark a section as present (idempotent). */ +export function includeSection(document: InterchangeDocument, section: InterchangeSection): void { + if (!document.contents.includes(section)) document.contents.push(section); +} + +/** A profile may only reference mods the document carries; dangling entries + * are dropped with a warning, never guessed. */ +export function dropDanglingProfileEntries(document: InterchangeDocument): void { + const keys = new Set(document.mods.map((m) => m.key)); + for (const profile of document.profiles) { + const before = profile.mods.length; + profile.mods = profile.mods.filter((entry) => keys.has(entry.modKey)); + const dropped = before - profile.mods.length; + if (dropped > 0) { + document.warnings.push( + `Profile ${profile.name}: ${dropped} entry(s) point at mods that are not available` + ); + } + } +} + +function parseProfile(raw: unknown): InterchangeProfile | string { + if (!isObject(raw)) return 'not an object'; + const key = optString(raw.key); + const name = optString(raw.name); + if (!key) return 'it has no key'; + if (!name) return `${key} has no name`; + const mods = (Array.isArray(raw.mods) ? raw.mods : []) + .filter(isObject) + .flatMap((entry, index) => { + const modKey = optString(entry.modKey); + if (!modKey) return []; + return [ + { + modKey, + enabled: entry.enabled === true, + order: typeof entry.order === 'number' && Number.isFinite(entry.order) ? entry.order : index, + }, + ]; + }); + const autoexec = Array.isArray(raw.autoexec) + ? raw.autoexec.filter((c): c is string => typeof c === 'string') + : null; + return { + key, + name, + active: raw.active === true, + description: optString(raw.description) ?? null, + mods, + crosshairKey: optString(raw.crosshairKey) ?? null, + autoexec: autoexec && autoexec.length > 0 ? autoexec : null, + }; +} + +function parseCrosshair(raw: unknown): InterchangeCrosshair | string { + if (!isObject(raw)) return 'not an object'; + const key = optString(raw.key); + if (!key) return 'it has no key'; + const convars: Record = {}; + if (isObject(raw.convars)) { + for (const [name, value] of Object.entries(raw.convars)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + convars[name] = String(value); + } + } + } + return { key, name: optString(raw.name) ?? key, active: raw.active === true, convars }; +} + +function parseSection( + raw: unknown, + label: string, + parse: (entry: unknown) => T | string, + warnings: string[] +): T[] { + const out: T[] = []; + const seen = new Set(); + (Array.isArray(raw) ? raw : []).forEach((entry, index) => { + const parsed = parse(entry); + if (typeof parsed === 'string') warnings.push(`Skipped ${label} #${index}: ${parsed}`); + else if (seen.has(parsed.key)) warnings.push(`Skipped duplicate ${label} key ${parsed.key}`); + else { + seen.add(parsed.key); + out.push(parsed); + } + }); + return out; +} + +/** A value is usable as a file name only if it is one plain `*.vpk` component. */ +export function isPlainVpkName(name: string): boolean { + return ( + name.length > 0 && + !name.includes('/') && + !name.includes('\\') && + name !== '.' && + name !== '..' && + name.toLowerCase().endsWith('.vpk') + ); +} + +export function gameBananaKey(submissionType: 'mod' | 'sound', submissionId: string | number): string { + return `gamebanana:${submissionType}:${submissionId}`; +} + +function parseOrigin(raw: unknown): InterchangeOrigin | null { + if (!isObject(raw)) return null; + if (raw.provider === 'gamebanana') { + const id = typeof raw.submissionId === 'number' ? String(raw.submissionId) : raw.submissionId; + if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) return null; + const submissionType = raw.submissionType === 'sound' ? 'sound' : 'mod'; + const fileId = + typeof raw.fileId === 'number' && Number.isSafeInteger(raw.fileId) && raw.fileId > 0 + ? raw.fileId + : undefined; + return { provider: 'gamebanana', submissionType, submissionId: id, fileId, fileName: optString(raw.fileName) }; + } + if (raw.provider === 'local') { + return { provider: 'local', localId: optString(raw.localId) }; + } + return null; +} + +function parseFile(raw: unknown): InterchangeFile | null { + if (!isObject(raw)) return null; + const name = optString(raw.name); + const path = optString(raw.path); + if (!name || !path) return null; + return { + name, + path, + sha256: optString(raw.sha256), + size: typeof raw.size === 'number' && raw.size >= 0 ? raw.size : undefined, + selected: typeof raw.selected === 'boolean' ? raw.selected : undefined, + }; +} + +function parseMod(raw: unknown): InterchangeMod | string { + if (!isObject(raw)) return 'not an object'; + const key = optString(raw.key); + const name = optString(raw.name); + if (!key) return 'it has no key'; + if (!name) return `${key} has no name`; + const origin = parseOrigin(raw.origin); + if (!origin) return `${name} has an unsupported origin`; + const files = (Array.isArray(raw.files) ? raw.files : []) + .map(parseFile) + .filter((f): f is InterchangeFile => f !== null); + const text = (field: string) => optString(raw[field]) ?? null; + return { + key, + name, + enabled: raw.enabled === true, + order: typeof raw.order === 'number' && Number.isFinite(raw.order) ? raw.order : 0, + origin, + author: text('author'), + description: text('description'), + category: text('category'), + hero: text('hero'), + thumbnailUrl: text('thumbnailUrl'), + link: text('link'), + nsfw: typeof raw.nsfw === 'boolean' ? raw.nsfw : null, + files, + extensions: isObject(raw.extensions) + ? (raw.extensions as Record>) + : undefined, + }; +} + +/** + * Parse a document leniently: the envelope must be valid, but one broken mod + * entry only costs that entry (as a warning), never the whole import. + * Untrusted input: throws a message the import UI can show verbatim. + */ +export function parseInterchangeDocument(text: string): InterchangeDocument { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (err) { + throw new Error( + `${INTERCHANGE_MANIFEST} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (!isObject(raw)) throw new Error(`${INTERCHANGE_MANIFEST} must be a JSON object`); + if (raw.format !== INTERCHANGE_FORMAT) { + throw new Error( + `Not a mod interchange document (format is "${String(raw.format)}", expected "${INTERCHANGE_FORMAT}")` + ); + } + if (typeof raw.version !== 'number' || raw.version < 1 || raw.version > INTERCHANGE_VERSION) { + throw new Error( + `Unsupported interchange version ${String(raw.version)} (this build understands ${INTERCHANGE_VERSION})` + ); + } + + const warnings = (Array.isArray(raw.warnings) ? raw.warnings : []).filter( + (w): w is string => typeof w === 'string' + ); + const mods: InterchangeMod[] = []; + const seen = new Set(); + (Array.isArray(raw.mods) ? raw.mods : []).forEach((entry, index) => { + const parsed = parseMod(entry); + if (typeof parsed === 'string') { + warnings.push(`Skipped mod #${index}: ${parsed}`); + } else if (seen.has(parsed.key)) { + warnings.push(`Skipped duplicate mod key ${parsed.key}`); + } else { + seen.add(parsed.key); + mods.push(parsed); + } + }); + + const source = isObject(raw.source) ? raw.source : {}; + const document: InterchangeDocument = { + format: INTERCHANGE_FORMAT, + version: raw.version, + createdAt: optString(raw.createdAt), + source: { + manager: optString(source.manager) ?? 'unknown', + managerVersion: optString(source.managerVersion), + profileName: optString(source.profileName), + }, + contents: ['mods'], + mods, + profiles: parseSection(raw.profiles, 'profile', parseProfile, warnings), + crosshairs: parseSection(raw.crosshairs, 'crosshair', parseCrosshair, warnings), + warnings, + }; + dropDanglingProfileEntries(document); + if (document.profiles.length > 0) includeSection(document, 'profiles'); + if (document.crosshairs.length > 0) includeSection(document, 'crosshairs'); + return document; +} + +// --- Wire types for the renderer <-> main IPC ------------------------------- + +export type InterchangeSourceRequest = + | { + kind: 'manager'; + /** Registry id of the mod manager, e.g. `deadlock-mod-manager`. */ + id: string; + /** A folder the user picked when auto-detection failed. */ + location?: string; + } + | { kind: 'bundle'; path: string }; + +/** A mod manager Grimoire can read directly from disk. */ +export interface InterchangeSourceInfo { + id: string; + name: string; + found: boolean; + location: string | null; + sections: InterchangeSection[]; + searched: string[]; +} + +export type InterchangeEntryStatus = 'new' | 'managed' | 'unknown-catalog'; + +export interface InterchangePreview { + document: InterchangeDocument; + /** Per key: whether Grimoire already has it or would reject it. */ + status: Record; +} + +export interface InterchangeImportSelection { + modKeys: string[]; + profileKeys: string[]; + crosshairKeys: string[]; +} + +export interface InterchangeImportResult { + key: string; + name: string; + status: 'imported' | 'skipped' | 'failed'; + reason?: string; + installedAs?: string; + /** Every adopted VPK a profile loads for this mod (multi-VPK mods have + * several); unselected variants are left out. */ + installedKeys?: string[]; + /** The mod's other installed VPKs (unselected variants, files Grimoire + * keeps off). A profile records them as disabled. */ + variantKeys?: string[]; + /** Grimoire mod id of the (first) adopted VPK. */ + modId?: string; + /** True when the mod arrived without a GameBanana identity. */ + local?: boolean; +} + +export interface InterchangeImportReport { + results: InterchangeImportResult[]; + /** `updated`: a profile an earlier import created was refreshed instead. */ + profiles: Array<{ name: string; created: boolean; updated?: boolean; mods: number; reason?: string }>; + crosshairs: number; + warnings: string[]; +} + +export interface InterchangeProgress { + stage: 'mods' | 'profiles' | 'crosshairs'; + current: number; + total: number; + name: string; +} + +export interface InterchangeExportSelection { + profiles: boolean; + crosshairs: boolean; +} + +export interface InterchangeExportReport { + bundlePath: string; + exported: number; + profiles: number; + crosshairs: number; + skipped: Array<{ name: string; reason: string }>; +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8fd76b4f..60257ea9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1352,15 +1352,6 @@ "fixHiddenHint": "Fix unknown is hidden. Right-click to bring it back.", "fixButtonHint": "Find GameBanana matches or add custom metadata for unknown local mods. Right-click to hide this button.", "fixButton": "Fix unknown ({{count}})", - "dmmDetected": "Importing Deadlock Mod Manager mods...", - "dmmConfirmTitle": "Import Deadlock Mod Manager mods?", - "dmmConfirmMessage_one": "Grimoire found a Deadlock Mod Manager library (profile: {{profile}}) with {{count}} mod it doesn't manage yet. Import it? A snapshot and a metadata backup are taken first, and Deadlock Mod Manager's own files are never moved or deleted. Skipping goes straight to manual identification.", - "dmmConfirmMessage_other": "Grimoire found a Deadlock Mod Manager library (profile: {{profile}}) with {{count}} mods it doesn't manage yet. Import them? A snapshot and a metadata backup are taken first, and Deadlock Mod Manager's own files are never moved or deleted. Skipping goes straight to manual identification.", - "dmmConfirmImport": "Import", - "dmmConfirmSkip": "Skip", - "dmmImported_one": "Imported {{count}} mod from Deadlock Mod Manager", - "dmmImported_other": "Imported {{count}} mods from Deadlock Mod Manager", - "dmmImportFailed": "Could not import Deadlock Mod Manager mods", "checkingCrcCache": "Checking local CRC cache...", "noCachedMatch": "No cached match found. Queued for online search.", "missingMetadata": "Matched mod is missing GameBanana metadata", @@ -2311,13 +2302,15 @@ "preUpdate": "Before mod update", "preApplyProfile": "Before applying profile", "preDmmImport": "Before DMM import", - "manual": "Manual snapshot" + "manual": "Manual snapshot", + "preModImport": "Before mod import" }, "explanation": { "preUpdate": "Captured before a mod update changed your installed set.", "preApplyProfile": "Captured before applying a profile changed your installed set.", "preDmmImport": "Captured before importing mods from Deadlock Mod Manager.", - "manual": "Captured manually from the Profiles page." + "manual": "Captured manually from the Profiles page.", + "preModImport": "Captured before importing mods from another mod manager export." }, "unselect": "Unselect snapshot", "select": "Select snapshot", @@ -3241,5 +3234,98 @@ "body": "DeadlockForge builds mods in your browser and can hand them straight to Grimoire, so you do not have to download the file and move it into your game folder yourself.", "note": "Grimoire will listen only on your own machine, accept mods only from deadlockforge.net, and ask you to confirm every single install. You can turn this off any time in Settings." } + }, + "interchange": { + "importButton": "Import from other mod managers", + "importButtonHint": "Bring mods, profiles and crosshairs over from Deadlock Mod Manager or another manager's export", + "managersLabel": "Mod managers", + "detecting": "Looking for installed mod managers…", + "detected": "Detected", + "notDetected": "Not found", + "pickManagerFolder": "Choose the {{source}} folder", + "chooseManagerFolder": "Choose {{source}} folder…", + "pickBundle": "Open a mod-interchange.json", + "openBundle": "Open export file…", + "exportFile": "export file", + "importTitle": "Import from {{source}}", + "stepDescription": { + "loading": "Reading your {{source}} library.", + "contents": "Choose what to bring over. Mods in the shared addons folder are moved and renamed for Grimoire, so {{source}} may lose track of them.", + "contentsBundle": "Choose what to bring over. The {{source}} itself is not changed.", + "importing": "Copying mods and settings.", + "unrecognized": "These mods arrived without a GameBanana link. Identify them to get updates and details, or skip.", + "done": "The import is finished." + }, + "scanning": "Reading the {{source}} library…", + "emptySource": "{{source}} has no mods to import.", + "sectionMods": "Mods", + "sectionModsDetail_one": "{{count}} new mod of {{total}}, with its enabled state and load order", + "sectionModsDetail_other": "{{count}} new mods of {{total}}, with their enabled state and load order", + "chooseMods": "Choose individual mods", + "fileCount_one": "{{count}} file", + "fileCount_other": "{{count}} files", + "inLibrary": "Already in Grimoire", + "unknownCatalog": "Unknown id", + "local": "Local", + "enabled": "Enabled", + "disabled": "Disabled", + "active": "Active", + "sectionProfiles": "Profiles", + "sectionProfilesDetail_one": "Create {{count}} Grimoire profile; the mods it needs are imported too", + "sectionProfilesDetail_other": "Create Grimoire profiles for the selected {{count}}; the mods they need are imported too", + "profileMods_one": "{{count}} mod", + "profileMods_other": "{{count}} mods", + "noProfiles": "{{source}} has no profiles to import", + "sectionCrosshairs": "Crosshairs", + "sectionCrosshairsDetail_one": "{{count}} crosshair becomes a preset", + "sectionCrosshairsDetail_other": "{{count}} crosshairs become presets", + "noCrosshairs": "{{source}} has no crosshairs to import", + "warnings_one": "{{count}} note about the source data", + "warnings_other": "{{count}} notes about the source data", + "startImport": "Import", + "progress": { + "starting": "Preparing…", + "mods": "Importing mods ({{current}}/{{total}})", + "profiles": "Creating profiles ({{current}}/{{total}})", + "crosshairs": "Adding crosshairs ({{current}}/{{total}})" + }, + "unrecognizedHint_one": "{{count}} mod has no GameBanana link.", + "unrecognizedHint_other": "{{count}} mods have no GameBanana link.", + "analyze": "Analyze", + "analyzeResult_one": "Found a match for {{count}} mod", + "analyzeResult_other": "Found matches for {{count}} mods", + "suggestion": "Looks like {{name}}", + "useSuggestion": "Use", + "noMatch": "No match found. Paste the GameBanana link below.", + "linkPlaceholder": "GameBanana link or ID", + "link": "Link", + "linkedBadge": "Linked: {{name}}", + "invalidReference": "That is not a GameBanana mod or sound link", + "continue": "Continue", + "skipIdentification": "Skip", + "summary": "{{imported}} imported, {{skipped}} skipped", + "profileCreated_one": "Created profile {{name}} with {{count}} mod", + "profileCreated_other": "Created profile {{name}} with {{count}} mods", + "profileUpdated_one": "Updated profile {{name}} with {{count}} mod", + "profileUpdated_other": "Updated profile {{name}} with {{count}} mods", + "profileFailed": "Profile {{name}} could not be created: {{error}}", + "crosshairsAdded_one": "Added {{count}} crosshair preset", + "crosshairsAdded_other": "Added {{count}} crosshair presets", + "linkedCount_one": "Linked {{count}} mod to GameBanana", + "linkedCount_other": "Linked {{count}} mods to GameBanana", + "importFailed": "Import failed: {{error}}", + "exportMenu": "Export for other mod managers", + "exportHint": "Save your mods, profiles and crosshairs as a bundle Deadlock Mod Manager and other managers can import", + "exportTitle": "Export for other mod managers", + "exportDescription": "Saves a mod-interchange bundle other mod managers can import.", + "exportModsDetail": "Every mod with its files, names and GameBanana links", + "exportProfilesDetail": "All profiles with their mods, crosshairs and autoexec commands", + "exportCrosshairsDetail": "All crosshair presets", + "exporting": "Copying mods ({{current}}/{{total}})", + "exportedSummary": "Exported {{mods}} mods, {{profiles}} profiles and {{crosshairs}} crosshairs", + "openFolder": "Open folder", + "chooseFolderAndExport": "Choose folder and export", + "exportPickFolder": "Choose where to save the export", + "exportFailed": "Export failed: {{error}}" } } diff --git a/src/locales/manifest.json b/src/locales/manifest.json index 6f6ccef5..f2287a80 100644 --- a/src/locales/manifest.json +++ b/src/locales/manifest.json @@ -1,24 +1,24 @@ { "sourceLanguage": "en", - "totalKeys": 2539, + "totalKeys": 2619, "languages": [ { "code": "bg", "name": "български", - "translatedKeys": 1915, - "pct": 75 + "translatedKeys": 1906, + "pct": 73 }, { "code": "en", "name": "English", - "translatedKeys": 2539, + "translatedKeys": 2619, "pct": 100 }, { "code": "fr", "name": "français", - "translatedKeys": 2114, - "pct": 83 + "translatedKeys": 2105, + "pct": 80 }, { "code": "ru", diff --git a/src/pages/Installed.tsx b/src/pages/Installed.tsx index d8a9213c..b902087c 100644 --- a/src/pages/Installed.tsx +++ b/src/pages/Installed.tsx @@ -28,6 +28,7 @@ import { Trash2, AlertTriangle, FolderOpen, + FileOutput, FilePlus, Files, X, @@ -57,7 +58,7 @@ import { showToast } from '../stores/toastStore'; import { useAppStore, type BrowseArtistRef } from '../stores/appStore'; import { getActiveDeadlockPath, shouldBlurNsfw } from '../lib/appSettings'; import { isImprintPending } from '../lib/imprintPending'; -import { getConflicts, openModsFolder, getModDetails, getModFileList, downloadMod, createSnapshot, deleteMod as deleteModApi, assertReplacementSafety, detectUnknownModFilters, detectUnknownModCacheBulk, cancelUnknownModDetection, onUnknownModDetectionProgress, applyUnknownModMatch, applyUnknownCustomMod, associateUnknownMod, mergeMods, unmergeMod, extractMergeSource, addMergeSources, replaceMergeSources, reorderMods as apiReorderMods, restoreLocalVariantGroupReplacement, setModIgnoreUpdates, getLockerOverview, dmmMigrateScan, dmmMigrateExecute, imprintAllInstalled, onImprintAllInstalledProgress, imprintPreflight, launchModded } from '../lib/api'; +import { getConflicts, openModsFolder, getModDetails, getModFileList, downloadMod, createSnapshot, deleteMod as deleteModApi, assertReplacementSafety, detectUnknownModFilters, detectUnknownModCacheBulk, cancelUnknownModDetection, onUnknownModDetectionProgress, applyUnknownModMatch, applyUnknownCustomMod, associateUnknownMod, mergeMods, unmergeMod, extractMergeSource, addMergeSources, replaceMergeSources, reorderMods as apiReorderMods, restoreLocalVariantGroupReplacement, setModIgnoreUpdates, getLockerOverview, imprintAllInstalled, onImprintAllInstalledProgress, imprintPreflight, launchModded } from '../lib/api'; import type { UnmergeModResult, ImportCustomModArgs, ImportCustomModResult } from '../lib/api'; import type { ModConflict } from '../lib/api'; import type { Mod, GlobalModType, UnknownModDetectionProgress, UnknownModFilterGuess, MergedModSource, MergeSourceReplacement, AssociateUnknownModArgs } from '../types/mod'; @@ -69,6 +70,8 @@ import { type MergeSourceUpdateSkip, } from '../lib/mergeSourceUpdate'; import ModDetailsModal from '../components/ModDetailsModal'; +import ImportSourceMenu from '../components/interchange/ImportSourceMenu'; +import ExportModal from '../components/interchange/ExportModal'; import VariantPickerModal from '../components/VariantPickerModal'; import ImportCustomModsModal from '../components/ImportCustomModsModal'; import MergeModsModal from '../components/MergeModsModal'; @@ -1015,20 +1018,9 @@ export default function Installed() { cancelled?: boolean; } | null>(null); const [unknownFixMode, setUnknownFixMode] = useState<'single' | 'bulk' | null>(null); - // Synchronous re-entry guard for the DMM auto-import step (one click only, - // even if the button is mashed before the first run resolves). A ref, not - // state, so two clicks in the same tick can't both read a stale `false`. - const dmmAutoImportInFlightRef = useRef(false); - const [dmmAutoImporting, setDmmAutoImporting] = useState(false); + const [exportOpen, setExportOpen] = useState(false); const soloBusyRef = useRef(false); const [soloBusy, setSoloBusy] = useState(false); - // Pending DMM-import consent dialog. Holds the promise resolver so - // autoImportDmmMods can await the user's answer; null = no dialog. - const [dmmConfirm, setDmmConfirm] = useState<{ - count: number; - profileName: string; - resolve: (ok: boolean) => void; - } | null>(null); const [unknownFilterCache, setUnknownFilterCache] = useState>({}); const [unknownFilterPendingIds, setUnknownFilterPendingIds] = useState>(new Set()); const [unknownFilterErrors, setUnknownFilterErrors] = useState>({}); @@ -1635,95 +1627,12 @@ export default function Installed() { ); }; - // Adopt any Deadlock Mod Manager install as the first step of the unknown-fix - // flow. DMM detection is purely local (reads DMM's on-disk data, no - // GameBanana, no rate limit), so it runs regardless of the auto-match toggle. - // Importing tags the matching unknown VPKs with GameBanana metadata, so they - // drop out of the unknown set before the manual modal opens. Returns the - // unknown mods that remain afterward (the input list unchanged when there's - // no DMM install or nothing new to adopt). - const autoImportDmmMods = async (currentUnknowns: Mod[]): Promise => { - if (currentUnknowns.length === 0) return currentUnknowns; - - // Probe for a DMM install. Scan throws when there's no DMM data on this - // machine (the common case): handled silently so the manual flow proceeds. - let scan; - try { - scan = await dmmMigrateScan({}); - } catch { - return currentUnknowns; - } - - // Only act on DMM mods Grimoire isn't already managing. Without this gate a - // user with DMM installed would see "detected, importing" on every Fix - // Unknown click, even after everything had already been adopted. - const managedGbIds = new Set( - useAppStore - .getState() - .mods.map((m) => m.gameBananaId) - .filter((id): id is number => !!id) - ); - const freshEntries = scan.preview.filter((p) => !managedGbIds.has(p.submissionId)); - if (freshEntries.length === 0) return currentUnknowns; - - // Consent gate: importing writes mod identities in batch, so it never - // runs on a toast alone. The dialog says what was found; declining goes - // straight to manual identification. - const consent = await new Promise((resolve) => - setDmmConfirm({ count: freshEntries.length, profileName: scan.profileName, resolve }) - ); - if (!consent) return currentUnknowns; - - showToast(t('installed.unknown.dmmDetected'), { tone: 'info', duration: 4000 }); - - let report; - try { - report = await dmmMigrateExecute({}); - } catch (err) { - showToast( - t('installed.unknown.dmmImportFailed') + ': ' + (err instanceof Error ? err.message : String(err)), - { tone: 'error', duration: 8000, dismissable: true } - ); - return currentUnknowns; - } - - await loadMods(); - const remaining = useAppStore - .getState() - .mods.filter((m) => m.isUnknown) - .sort((a, b) => a.priority - b.priority); - - if (report.adopted.length > 0) { - showToast(t('installed.unknown.dmmImported', { count: report.adopted.length }), { - tone: 'success', - duration: 5000, - }); - } - return remaining; - }; - - const openBulkUnknownFix = async (unknowns: Mod[]) => { - // Ignore re-entrant clicks while a DMM auto-import is mid-flight: the import - // mutates files + reloads mods, so a second concurrent run would race the - // first (main-process serialization keeps it safe, but it's wasted work and - // a double toast). The button also shows a loading state via dmmAutoImporting. - if (dmmAutoImportInFlightRef.current) return; - dmmAutoImportInFlightRef.current = true; - setDmmAutoImporting(true); - let remaining: Mod[]; - try { - // First adopt any Deadlock Mod Manager mods automatically, then open the - // manual modal on whatever unknowns are left. - remaining = await autoImportDmmMods(unknowns); - } finally { - dmmAutoImportInFlightRef.current = false; - setDmmAutoImporting(false); - } - const first = remaining[0]; + const openBulkUnknownFix = (unknowns: Mod[]) => { + const first = unknowns[0]; if (!first) return; openUnknownModFix(first, 'bulk'); - // The heavy auto-detect sweep is no longer kicked on open. The bulk modal - // lists the unknowns so the user can search/link each one manually; the + // The heavy auto-detect sweep is not kicked on open. The bulk modal lists + // the unknowns so the user can search/link each one manually; the // "Auto-detect all" button (behind a rate-limit confirm) still runs the // CRC matcher across the batch when explicitly requested. }; @@ -4044,6 +3953,7 @@ export default function Installed() { + void loadMods()} />
    } /> @@ -4122,8 +4032,6 @@ export default function Installed() { setFixUnknownHidden(true); }} icon={HelpCircle} - isLoading={dmmAutoImporting} - disabled={dmmAutoImporting} title={t('installed.unknown.fixButtonHint')} > {t('installed.unknown.fixButton', { count: unknownMods.length })} @@ -4438,6 +4346,15 @@ export default function Installed() { aria-label={t('installed.actions.addCustomMod')} title={t('installed.actions.addCustomModHint')} /> + void loadMods()} /> +