Skip to content

Commit

Permalink
Add RecursiveMelonLoaderInstaller
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
anttimaki committed Feb 18, 2025
1 parent eb4dd72 commit b73c3fe
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 5 deletions.
138 changes: 138 additions & 0 deletions src/installers/RecursiveMelonLoaderInstaller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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/<PackageName>/ and everything else
* to Mods/<PackageName>/
*/
async install(args: InstallArgs): Promise<void> {
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<void> {
try {
FileUtils.recursiveRemoveDirectoryIfExists(this.getModsPath(args));
FileUtils.recursiveRemoveDirectoryIfExists(this.getUserDataPath(args));
} catch (e) {
this.throwActionError(e, 'uninstall');
}
}

async disable(args: InstallArgs): Promise<void> {
try {
await disableModByRenamingFiles(this.getModsPath(args));
} catch (e) {
this.throwActionError(e, 'disable');
}
}

async enable(args: InstallArgs): Promise<void> {
try {
await enableModByRenamingFiles(this.getModsPath(args));
} catch (e) {
this.throwActionError(e, 'enable');
}
}
}
3 changes: 3 additions & 0 deletions src/installers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/model/installing/PackageLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum PackageLoader {
LOVELY,
RETURN_OF_MODDING,
GDWEAVE,
RECURSIVE_MELON_LOADER,
}

export function GetInstallerIdForLoader(loader: PackageLoader): PackageInstallerId | null {
Expand All @@ -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;
}
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
11 changes: 6 additions & 5 deletions src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -135,15 +136,15 @@ 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,
TheOuroborosKing: MODLOADER_PACKAGES,
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,
Expand Down
1 change: 1 addition & 0 deletions src/r2mm/launching/instructions/GameInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default class GameInstructions {
public static LOADER_INSTRUCTIONS: Map<PackageLoader, GameInstructionGenerator> = 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()],
Expand Down

0 comments on commit b73c3fe

Please sign in to comment.