From b73c3fe9b251cc3b8eb0749d4eb9d48fea66e9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 9 Dec 2024 15:41:28 +0200 Subject: [PATCH] Add RecursiveMelonLoaderInstaller MelonLoader has added support for recursive loading. This means mods, plugins and userLibs can all be placed in the same folder inside the profile folder instead of needing to copy each type to a specific subfolder. This in turn means we can namespace each installed mod into its own subfolder, which makes uninstalling the mods straightforward and removes the need to track installation state on a separate file. The mod author still needs to place plugins and userLibs to specific folders in their Thunderstore packages though. In the profile, package's UserData folder (if any) gets copied to profile/UserData/[packageName] and everything else gets copied to profile/Mods/[packageName]. MelonLoader handles the rest. This will require MelonLoader v0.7.0 or newer to work. --- .../RecursiveMelonLoaderInstaller.ts | 138 ++++++++++++++++++ src/installers/registry.ts | 3 + src/model/installing/PackageLoader.ts | 3 + .../PlatformInterceptorImpl.ts | 1 + .../ModLoaderVariantRecord.ts | 11 +- .../instructions/GameInstructions.ts | 1 + 6 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/installers/RecursiveMelonLoaderInstaller.ts diff --git a/src/installers/RecursiveMelonLoaderInstaller.ts b/src/installers/RecursiveMelonLoaderInstaller.ts new file mode 100644 index 000000000..4ab90f2ba --- /dev/null +++ b/src/installers/RecursiveMelonLoaderInstaller.ts @@ -0,0 +1,138 @@ +import path from 'path'; + +import { + disableModByRenamingFiles, + enableModByRenamingFiles, + InstallArgs, + PackageInstaller +} from './PackageInstaller'; +import FileWriteError from '../model/errors/FileWriteError'; +import R2Error from '../model/errors/R2Error'; +import VersionNumber from '../model/VersionNumber'; +import FsProvider from '../providers/generic/file/FsProvider'; +import FileUtils from '../utils/FileUtils'; + +/** + * Handles (un)installation of MelonLoader v0.7.0 and above. + */ +export class RecursiveMelonLoaderInstaller implements PackageInstaller { + private static readonly TRACKED = ['MelonLoader', 'version.dll']; + + async install(args: InstallArgs): Promise { + const { mod, packagePath, profile } = args; + + // The RecursiveMelonLoaderPluginInstaller places mod files into subfolders in + // the profile folder. This is only supported on MelonLoader v0.7.0 and above. + // Therefore a game that uses RecursiveMelonLoaderPluginInstaller should always + // also use RecursiveMelonLoaderInstaller, which checks the mod loader version + // before installing. + if (!mod.getVersionNumber().isEqualOrNewerThan(new VersionNumber('0.7.0'))) { + throw new R2Error( + 'MelonLoader v0.7.0 or above is required', + `Choose a newer MelonLoader version to install. If newer versions + are not available, this is likely a bug in the mod manager.` + ); + } + + try { + for (const fileOrFolder of RecursiveMelonLoaderInstaller.TRACKED) { + const cachePath = path.join(packagePath, fileOrFolder); + const profilePath = profile.joinToProfilePath(fileOrFolder); + await FileUtils.copyFileOrFolder(cachePath, profilePath); + } + } catch (e) { + throw FileWriteError.fromThrownValue(e, 'Failed to install MelonLoader'); + } + } + + async uninstall(args: InstallArgs): Promise { + try { + for (const fileOrFolder of RecursiveMelonLoaderInstaller.TRACKED) { + const fullPath = args.profile.joinToProfilePath(fileOrFolder); + + if (!(await FsProvider.instance.exists(fullPath))) { + continue; + } + + if ((await FsProvider.instance.stat(fullPath)).isDirectory()) { + await FileUtils.recursiveRemoveDirectoryIfExists(fullPath); + } else { + await FsProvider.instance.unlink(fullPath); + } + } + } catch (e) { + throw FileWriteError.fromThrownValue(e, 'Failed to uninstall MelonLoader', 'Is the game still running?'); + } + } +} + +/** + * Handles mod operations in a RecursiveMelonLoaderInstaller compatible way. + */ +export class RecursiveMelonLoaderPluginInstaller implements PackageInstaller { + private getModsPath(args: InstallArgs): string { + return args.profile.joinToProfilePath('Mods', args.mod.getName()); + } + + private getUserDataPath(args: InstallArgs): string { + return args.profile.joinToProfilePath('UserData', args.mod.getName()); + } + + private throwActionError(e: unknown, action: string): void { + const name = `Failed to ${action} mod`; + const solution = 'Is the game still running?'; + throw FileWriteError.fromThrownValue(e, name, solution); + } + + /** + * Copy UserData as-is to UserData// and everything else + * to Mods// + */ + async install(args: InstallArgs): Promise { + const files = await FsProvider.instance.readdir(args.packagePath); + const modsPath = this.getModsPath(args); + await FileUtils.ensureDirectory(modsPath); + + try { + for (const item of files) { + const sourceFull = path.join(args.packagePath, item); + + if (item === 'UserData') { + const userDataPath = this.getUserDataPath(args); + await FileUtils.ensureDirectory(userDataPath); + await FileUtils.copyFileOrFolder(sourceFull, userDataPath); + } else { + const targetPath = path.join(modsPath, item); + await FileUtils.copyFileOrFolder(sourceFull, targetPath); + } + } + } catch (e) { + this.throwActionError(e, 'install'); + } + } + + async uninstall(args: InstallArgs): Promise { + try { + FileUtils.recursiveRemoveDirectoryIfExists(this.getModsPath(args)); + FileUtils.recursiveRemoveDirectoryIfExists(this.getUserDataPath(args)); + } catch (e) { + this.throwActionError(e, 'uninstall'); + } + } + + async disable(args: InstallArgs): Promise { + try { + await disableModByRenamingFiles(this.getModsPath(args)); + } catch (e) { + this.throwActionError(e, 'disable'); + } + } + + async enable(args: InstallArgs): Promise { + try { + await enableModByRenamingFiles(this.getModsPath(args)); + } catch (e) { + this.throwActionError(e, 'enable'); + } + } +} diff --git a/src/installers/registry.ts b/src/installers/registry.ts index e0c3dc816..92da9f1b6 100644 --- a/src/installers/registry.ts +++ b/src/installers/registry.ts @@ -7,6 +7,7 @@ import { LovelyInstaller, LovelyPluginInstaller } from './LovelyInstaller'; import { NorthstarInstaller } from './NorthstarInstaller'; import { ReturnOfModdingInstaller, ReturnOfModdingPluginInstaller } from './ReturnOfModdingInstaller'; import { GDWeaveInstaller, GDWeavePluginInstaller } from './GDWeaveInstaller'; +import { RecursiveMelonLoaderInstaller, RecursiveMelonLoaderPluginInstaller } from './RecursiveMelonLoaderInstaller'; const _PackageInstallers = { // "legacy": new InstallRuleInstaller(), // TODO: Enable @@ -22,6 +23,8 @@ const _PackageInstallers = { "returnofmodding-plugin": new ReturnOfModdingPluginInstaller(), "gdweave": new GDWeaveInstaller(), "gdweave-plugin": new GDWeavePluginInstaller(), + "recursive-melonloader": new RecursiveMelonLoaderInstaller(), + "recursive-melonloader-plugin": new RecursiveMelonLoaderPluginInstaller(), } export type PackageInstallerId = keyof typeof _PackageInstallers; diff --git a/src/model/installing/PackageLoader.ts b/src/model/installing/PackageLoader.ts index f984f3d5f..818d65210 100644 --- a/src/model/installing/PackageLoader.ts +++ b/src/model/installing/PackageLoader.ts @@ -10,6 +10,7 @@ export enum PackageLoader { LOVELY, RETURN_OF_MODDING, GDWEAVE, + RECURSIVE_MELON_LOADER, } export function GetInstallerIdForLoader(loader: PackageLoader): PackageInstallerId | null { @@ -24,6 +25,7 @@ export function GetInstallerIdForLoader(loader: PackageLoader): PackageInstaller case PackageLoader.LOVELY: return "lovely"; case PackageLoader.RETURN_OF_MODDING: return "returnofmodding"; case PackageLoader.GDWEAVE: return "gdweave"; + case PackageLoader.RECURSIVE_MELON_LOADER: return "recursive-melonloader"; case PackageLoader.ANCIENT_DUNGEON_VR: return null; } } @@ -34,6 +36,7 @@ export function GetInstallerIdForPlugin(loader: PackageLoader): PackageInstaller case PackageLoader.LOVELY: return "lovely-plugin"; case PackageLoader.RETURN_OF_MODDING: return "returnofmodding-plugin"; case PackageLoader.GDWEAVE: return "gdweave-plugin"; + case PackageLoader.RECURSIVE_MELON_LOADER: return "recursive-melonloader-plugin"; } return null; diff --git a/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts b/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts index 5ecfc5483..729dd4ee2 100644 --- a/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts +++ b/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts @@ -67,6 +67,7 @@ function buildRunners(runners: PlatformRunnersType): LoaderRunnersType { [PackageLoader.LOVELY]: runners, [PackageLoader.RETURN_OF_MODDING]: runners, [PackageLoader.GDWEAVE]: runners, + [PackageLoader.RECURSIVE_MELON_LOADER]: runners, } } diff --git a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts index 4b5d1c62d..e59c8bcc0 100644 --- a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts +++ b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts @@ -74,7 +74,8 @@ export const MODLOADER_PACKAGES = [ new ModLoaderPackageMapping("BepInEx-BepInExPack_AmongUs", "BepInExPack_AmongUs", PackageLoader.BEPINEX), ]; -const DEFAULT_MELONLOADER_MAPPING = [new ModLoaderPackageMapping("LavaGang-MelonLoader", "", PackageLoader.MELON_LOADER)]; +const LEGACY_MELONLOADER_MAPPING = [new ModLoaderPackageMapping("LavaGang-MelonLoader", "", PackageLoader.MELON_LOADER)]; +const RECURSIVE_MELONLOADER_MAPPING = [new ModLoaderPackageMapping("LavaGang-MelonLoader", "", PackageLoader.RECURSIVE_MELON_LOADER)]; /** * While this object is a bit silly given that all the keys are pointing to the @@ -115,11 +116,11 @@ const VARIANTS = { Titanfall2: MODLOADER_PACKAGES, Peglin: MODLOADER_PACKAGES, VRising: MODLOADER_PACKAGES, - HardBullet: DEFAULT_MELONLOADER_MAPPING, + HardBullet: LEGACY_MELONLOADER_MAPPING, GreenHellVR: MODLOADER_PACKAGES, "20MinutesTillDawn": MODLOADER_PACKAGES, VTOL_VR: MODLOADER_PACKAGES, - BackpackHero: DEFAULT_MELONLOADER_MAPPING, + BackpackHero: LEGACY_MELONLOADER_MAPPING, Stacklands: MODLOADER_PACKAGES, ETG: MODLOADER_PACKAGES, Ravenfield: MODLOADER_PACKAGES, @@ -135,7 +136,7 @@ const VARIANTS = { AtrioTheDarkWild: MODLOADER_PACKAGES, AncientDungeonVR: MODLOADER_PACKAGES, Brotato: MODLOADER_PACKAGES, - RUMBLE: DEFAULT_MELONLOADER_MAPPING, + RUMBLE: LEGACY_MELONLOADER_MAPPING, DomeKeeper: MODLOADER_PACKAGES, SkulTheHeroSlayer: MODLOADER_PACKAGES, SonsOfTheForest: MODLOADER_PACKAGES, @@ -143,7 +144,7 @@ const VARIANTS = { WrestlingEmpire: MODLOADER_PACKAGES, Receiver2: MODLOADER_PACKAGES, ThePlanetCrafter: MODLOADER_PACKAGES, - PatchQuest: DEFAULT_MELONLOADER_MAPPING, + PatchQuest: LEGACY_MELONLOADER_MAPPING, ShadowsOverLoathing: MODLOADER_PACKAGES, WestofLoathing: MODLOADER_PACKAGES, SunHaven: MODLOADER_PACKAGES, diff --git a/src/r2mm/launching/instructions/GameInstructions.ts b/src/r2mm/launching/instructions/GameInstructions.ts index db3561b1d..a5197341a 100644 --- a/src/r2mm/launching/instructions/GameInstructions.ts +++ b/src/r2mm/launching/instructions/GameInstructions.ts @@ -23,6 +23,7 @@ export default class GameInstructions { public static LOADER_INSTRUCTIONS: Map = new Map([ [PackageLoader.BEPINEX, new BepInExGameInstructions()], [PackageLoader.MELON_LOADER, new MelonLoaderGameInstructions()], + [PackageLoader.RECURSIVE_MELON_LOADER, new MelonLoaderGameInstructions()], [PackageLoader.NORTHSTAR, new NorthstarGameInstructions()], [PackageLoader.GODOT_ML, new GodotMLGameInstructions()], [PackageLoader.ANCIENT_DUNGEON_VR, new AncientVRGameInstructions()],