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()],