Skip to content

Commit

Permalink
Improve performance of installing mods with multiple dependencies
Browse files Browse the repository at this point in the history
After the downloads are completed, the download modal hangs at 100% for
a moment while mods are copied to the profile folder and the mods.yml
file is updated.

The old implementation called ProfileModList.addMod() for each mod,
which meant that for each mod:

- The mods.yml was read six times, including checking if the file
  exists, reading it, parsing the yaml into ManifestV2[], and checking
  if *each* mod in the profile has icon file inside the profile folder
- The mods.yml was written twice, serializing the ManifestV2[] into
  yaml.
- For a modpack with 69 mods, all already in the cache, this took 25
  seconds

The new implementation tracks the contents of the ManifestV2[] in
memory. As a result mods.yml is read twice and saved once regardless
of the number of processed mods. For the aforementioned modpack this
takes four seconds.

It's worth noting that the implementations aren't completely identical
in other things. The new implementation doesn't necessarily define
which mod caused the error, whereas the old one did. The new
implementation doesn't give special treatment to bbepis-BepInExCack
anymore - it's now uninstalled like all the other files are.

Unfortunately this doesn't allow us to use the new implementation
with the static DownloadModModal.downloadSpecific(), nor clean up
the old implementation. That would require more refactoring, which
doesn't need to block these changes.
  • Loading branch information
anttimaki committed Oct 22, 2024
1 parent b5c3823 commit 27aa55a
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 14 deletions.
25 changes: 11 additions & 14 deletions src/components/views/DownloadModModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import ConflictManagementProvider from '../../providers/generic/installing/Confl
import { MOD_LOADER_VARIANTS } from '../../r2mm/installing/profile_installers/ModLoaderVariantRecord';
import ModalCard from '../ModalCard.vue';
import * as PackageDb from '../../r2mm/manager/PackageDexieStore';
import { installModsAfterDownload } from '../../utils/ProfileUtils';
interface DownloadProgress {
assignId: number;
Expand Down Expand Up @@ -370,24 +371,20 @@ let assignId = 0;
async downloadCompletedCallback(downloadedMods: ThunderstoreCombo[]) {
ProfileModList.requestLock(async () => {
for (const combo of downloadedMods) {
try {
await DownloadModModal.installModAfterDownload(this.profile, combo.getMod(), combo.getVersion());
} catch (e) {
this.downloadingMod = false;
const err = R2Error.fromThrownValue(e, `Failed to install mod [${combo.getMod().getFullName()}]`);
this.$store.commit('error/handleError', err);
return;
}
}
this.downloadingMod = false;
const modList = await ProfileModList.getModList(this.profile.asImmutableProfile());
if (!(modList instanceof R2Error)) {
const profile = this.profile.asImmutableProfile();
try {
const modList = await installModsAfterDownload(downloadedMods, profile);
await this.$store.dispatch('profile/updateModList', modList);
const err = await ConflictManagementProvider.instance.resolveConflicts(modList, this.profile);
if (err instanceof R2Error) {
this.$store.commit('error/handleError', err);
throw err;
}
} catch (e) {
this.$store.commit('error/handleError', R2Error.fromThrownValue(e));
} finally {
this.downloadingMod = false;
}
});
}
Expand Down
4 changes: 4 additions & 0 deletions src/model/ManifestV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,8 @@ export default class ManifestV2 implements ReactiveObjectConverterInterface {
public setInstalledAtTime(installedAtTime: number) {
this.installedAtTime = installedAtTime;
}

public getDependencyString(): string {
return `${this.getName()}-${this.getVersionNumber().toString()}`;
}
}
61 changes: 61 additions & 0 deletions src/utils/ProfileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import FsProvider from "../providers/generic/file/FsProvider";
import ZipProvider from "../providers/generic/zip/ZipProvider";
import ProfileInstallerProvider from "../providers/ror2/installing/ProfileInstallerProvider";
import * as PackageDb from '../r2mm/manager/PackageDexieStore';
import ProfileModList from "../r2mm/mods/ProfileModList";

export async function exportModsToCombos(exportMods: ExportMod[], game: Game): Promise<ThunderstoreCombo[]> {
const dependencyStrings = exportMods.map((m) => m.getDependencyString());
Expand Down Expand Up @@ -65,6 +66,66 @@ async function extractConfigsToImportedProfile(
}
}

/**
* Install mods to target profile and sync the changes to mods.yml file
* This is more performant than calling ProfileModList.addMod() on a
* loop, as that causes multiple disc operations per mod.
*/
export async function installModsAfterDownload(
comboList: ThunderstoreCombo[],
profile: ImmutableProfile
): Promise<ManifestV2[]> {
const profileMods = await ProfileModList.getModList(profile);
if (profileMods instanceof R2Error) {
throw profileMods;
}

const installedVersions = profileMods.map((m) => m.getDependencyString());
const disabledMods = profileMods.filter((m) => !m.isEnabled()).map((m) => m.getName());

try {
for (const comboMod of comboList) {
const manifestMod = new ManifestV2().fromThunderstoreMod(comboMod.getMod(), comboMod.getVersion());

if (installedVersions.includes(manifestMod.getDependencyString())) {
continue;
}

// Uninstall possible different version of the mod before installing the target version.
throwForR2Error(await ProfileInstallerProvider.instance.uninstallMod(manifestMod, profile));
throwForR2Error(await ProfileInstallerProvider.instance.installMod(manifestMod, profile));

if (disabledMods.includes(manifestMod.getName())) {
throwForR2Error(await ProfileInstallerProvider.instance.disableMod(manifestMod, profile));
manifestMod.disable();
}

manifestMod.setInstalledAtTime(Number(new Date()));
ProfileModList.setIconPath(manifestMod, profile);

const positionInProfile = profileMods.findIndex((m) => m.getName() === manifestMod.getName());
if (positionInProfile >= 0) {
profileMods[positionInProfile] = manifestMod;
} else {
profileMods.push(manifestMod);
}
}
} catch (e) {
const originalError = R2Error.fromThrownValue(e);
throw new R2Error(
'Installing downloaded mods to profile failed',
`
The mod and its dependencies might not be installed properly.
The original error was: ${originalError.name}: ${originalError.message}
`,
'The original error might provide hints about what went wrong.'
);
}

throwForR2Error(await ProfileModList.saveModList(profile, profileMods));
return profileMods;
}

/**
* Install mods to target profile without syncing changes to mods.yml file.
* Syncing is futile, as the mods.yml is copied from the imported profile.
Expand Down

0 comments on commit 27aa55a

Please sign in to comment.