package mindustry.mod; import arc.*; import arc.assets.*; import arc.files.*; import arc.func.*; import arc.graphics.*; import arc.graphics.Texture.*; import arc.graphics.g2d.*; import arc.graphics.g2d.TextureAtlas.*; import arc.scene.ui.*; import arc.struct.*; import arc.util.*; import arc.util.io.*; import arc.util.serialization.*; import arc.util.serialization.Jval.*; import mindustry.core.*; import mindustry.ctype.*; import mindustry.game.EventType.*; import mindustry.gen.*; import mindustry.graphics.*; import mindustry.graphics.MultiPacker.*; import mindustry.mod.ContentParser.*; import mindustry.type.*; import mindustry.ui.*; import java.io.*; import java.util.*; import java.util.concurrent.*; import static mindustry.Vars.*; public class Mods implements Loadable{ private static final String[] metaFiles = {"mod.json", "mod.hjson", "plugin.json", "plugin.hjson"}; private static final ObjectSet blacklistedMods = ObjectSet.with("ui-lib", "braindustry"); private Json json = new Json(); private @Nullable Scripts scripts; private ContentParser parser = new ContentParser(); private ObjectMap> bundles = new ObjectMap<>(); private ObjectSet specialFolders = ObjectSet.with("bundles", "sprites", "sprites-override", ".git"); /** Ordered mods cache. Set to null to invalidate. */ private @Nullable Seq lastOrderedMods = new Seq<>(); private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader()); Seq mods = new Seq<>(); private Seq newImports = new Seq<>(); private ObjectMap, ModMeta> metas = new ObjectMap<>(); private boolean requiresReload; public Mods(){ Events.on(ClientLoadEvent.class, e -> Core.app.post(this::checkWarnings)); } /** @return the main class loader for all mods */ public ClassLoader mainLoader(){ return mainLoader; } /** @return the folder where configuration files for this mod should go. Call this in init(). */ public Fi getConfigFolder(Mod mod){ ModMeta load = metas.get(mod.getClass()); if(load == null) throw new IllegalArgumentException("Mod is not loaded yet (or missing)!"); Fi result = modDirectory.child(load.name); result.mkdirs(); return result; } /** @return a file named 'config.json' in the config folder for the specified mod. * Call this in init(). */ public Fi getConfig(Mod mod){ return getConfigFolder(mod).child("config.json"); } /** Returns a list of files per mod subdirectory. */ public void listFiles(String directory, Cons2 cons){ eachEnabled(mod -> { Fi file = mod.root.child(directory); if(file.exists()){ for(Fi child : file.list()){ cons.get(mod, child); } } }); } /** @return the loaded mod found by name, or null if not found. */ public @Nullable LoadedMod getMod(String name){ return mods.find(m -> m.name.equals(name)); } /** @return the loaded mod found by class, or null if not found. */ public @Nullable LoadedMod getMod(Class type){ return mods.find(m -> m.main != null && m.main.getClass() == type); } /** Imports an external mod file. Folders are not supported here. */ public LoadedMod importMod(Fi file) throws IOException{ //for some reason, android likes to add colons to file names, e.g. primary:ExampleJavaMod.jar, which breaks dexing String baseName = file.nameWithoutExtension().replace(':', '_').replace(' ', '_'); String finalName = baseName; //find a name to prevent any name conflicts int count = 1; while(modDirectory.child(finalName + ".zip").exists()){ finalName = baseName + "" + count++; } Fi dest = modDirectory.child(finalName + ".zip"); try{ file.copyTo(dest); var loaded = loadMod(dest, true, true); mods.add(loaded); newImports.add(loaded); //invalidate ordered mods cache lastOrderedMods = null; requiresReload = true; //enable the mod on import Core.settings.put("mod-" + loaded.name + "-enabled", true); sortMods(); //try to load the mod's icon so it displays on import Core.app.post(() -> loadIcon(loaded)); Events.fire(Trigger.importMod); return loaded; }catch(IOException e){ dest.delete(); throw e; }catch(Throwable t){ dest.delete(); throw new IOException(t); } } /** Repacks all in-game sprites. */ @Override public void loadAsync(){ if(!mods.contains(LoadedMod::enabled)) return; long startTime = Time.millis(); //TODO this should estimate sprite sizes per page MultiPacker packer = new MultiPacker(); var textureResize = new ObjectFloatMap(); int[] totalSprites = {0}; //all packing tasks to await var tasks = new Seq>(); eachEnabled(mod -> { Seq sprites = mod.root.child("sprites").findAll(f -> f.extension().equals("png")); Seq overrides = mod.root.child("sprites-override").findAll(f -> f.extension().equals("png")); packSprites(packer, sprites, mod, true, tasks, textureResize); packSprites(packer, overrides, mod, false, tasks, textureResize); Log.debug("Packed @ images for mod '@'.", sprites.size + overrides.size, mod.meta.name); totalSprites[0] += sprites.size + overrides.size; }); for(var result : tasks){ try{ var packRun = result.get(); if(packRun != null){ //can be null for very strange reasons, ignore if that's the case try{ //actually pack the image packRun.run(); }catch(Exception e){ //the image can fail to fit in the spritesheet Log.err("Failed to fit image into the spritesheet, skipping."); Log.err(e); } } }catch(Exception e){ //this means loading the image failed, log it and move on Log.err(e); } } Log.debug("Total sprites: @", totalSprites[0]); TextureFilter filter = Core.settings.getBool("linear", true) ? TextureFilter.linear : TextureFilter.nearest; Texture[] whiteToDispose = {null}; class RegionEntry{ String name; PixmapRegion region; int[] splits, pads; RegionEntry(String name, PixmapRegion region, int[] splits, int[] pads){ this.name = name; this.region = region; this.splits = splits; this.pads = pads; } } Seq[] entries = new Seq[PageType.all.length]; for(int i = 0; i < PageType.all.length; i++){ entries[i] = new Seq<>(); } ObjectMap pageTypes = ObjectMap.of( Core.atlas.find("white").texture, PageType.main, Core.atlas.find("stone1").texture, PageType.environment, Core.atlas.find("whiteui").texture, PageType.ui, Core.atlas.find("rubble-1-0").texture, PageType.rubble ); for(AtlasRegion region : Core.atlas.getRegions()){ PageType type = pageTypes.get(region.texture, PageType.main); if(!packer.has(type, region.name)){ entries[type.ordinal()].add(new RegionEntry(region.name, Core.atlas.getPixmap(region), region.splits, region.pads)); } } //sort each page type by size first, for optimal packing for(int i = 0; i < PageType.all.length; i++){ var rects = entries[i]; var type = PageType.all[i]; //TODO is this in reverse order? rects.sort(Structs.comparingInt(o -> -Math.max(o.region.width, o.region.height))); for(var entry : rects){ packer.add(type, entry.name, entry.region, entry.splits, entry.pads); } } waitForMain(() -> { Core.atlas.dispose(); //dead shadow-atlas for getting regions, but not pixmaps var shadow = Core.atlas; //dummy texture atlas that returns the 'shadow' regions; used for mod loading Core.atlas = new TextureAtlas(){ boolean foundWhite; AtlasRegion whiteRegion; { //needed for the correct operation of the found() method in the TextureRegion error = shadow.find("error"); } @Override public AtlasRegion white(){ if(Core.app.isOnMainThread() && !foundWhite){ Pixmap pixmap = Pixmaps.blankPixmap(); Texture tex = new Texture(pixmap); whiteToDispose[0] = tex; return whiteRegion = new AtlasRegion(tex, 0, 0, 1, 1); } return super.white(); } @Override public AtlasRegion find(String name){ var base = packer.get(name); if(base != null){ var reg = new AtlasRegion(shadow.find(name).texture, base.x, base.y, base.width, base.height); reg.name = name; reg.pixmapRegion = base; return reg; } return shadow.find(name); } @Override public boolean isFound(TextureRegion region){ return region != shadow.find("error"); } @Override public TextureRegion find(String name, TextureRegion def){ return !has(name) ? def : find(name); } @Override public boolean has(String s){ return shadow.has(s) || packer.get(s) != null; } //return the *actual* pixmap regions, not the disposed ones. @Override public PixmapRegion getPixmap(AtlasRegion region){ PixmapRegion out = packer.get(region.name); //this should not happen in normal situations if(out == null) return packer.get("error"); return out; } }; }); //generate new icons for(Seq arr : content.getContentMap()){ arr.each(c -> { if(c instanceof UnlockableContent u && c.minfo.mod != null){ u.load(); u.loadIcon(); if(u.generateIcons && !c.minfo.mod.meta.pregenerated){ u.createIcons(packer); } } }); } waitForMain(() -> { if(whiteToDispose[0] != null){ whiteToDispose[0].dispose(); } //replace old atlas data Core.atlas = packer.flush(filter, new TextureAtlas(){ @Override public PixmapRegion getPixmap(AtlasRegion region){ var other = super.getPixmap(region); if(other.pixmap.isDisposed()){ throw new RuntimeException("Calling getPixmap outside of createIcons is not supported!"); } return other; } }); textureResize.each(e -> Core.atlas.find(e.key).scale = e.value); Core.atlas.setErrorRegion("error"); Log.debug("Total pages: @", Core.atlas.getTextures().size); packer.printStats(); Events.fire(new AtlasPackEvent()); packer.dispose(); Log.debug("Total time to pack and generate sprites: @ms", Time.timeSinceMillis(startTime)); }); } private void loadIcons(){ for(LoadedMod mod : mods){ loadIcon(mod); } } private void loadIcon(LoadedMod mod){ //try to load icon for each mod that can have one if(mod.root.child("icon.png").exists() && !headless){ try{ mod.iconTexture = new Texture(mod.root.child("icon.png")); mod.iconTexture.setFilter(TextureFilter.linear); }catch(Throwable t){ Log.err("Failed to load icon for mod '" + mod.name + "'.", t); } } } private void packSprites(MultiPacker packer, Seq sprites, LoadedMod mod, boolean prefix, Seq> tasks, ObjectFloatMap textureResize){ boolean bleed = Core.settings.getBool("linear", true) && !mod.meta.pregenerated; float textureScale = mod.meta.texturescale; for(Fi file : sprites){ String baseName = file.nameWithoutExtension(), regionName = baseName.contains(".") ? baseName.substring(0, baseName.indexOf(".")) : baseName; if(!prefix && !Core.atlas.has(regionName)){ Log.warn("Sprite '@' in mod '@' attempts to override a non-existent sprite.", regionName, mod.name); } //read and bleed pixmaps in parallel tasks.add(mainExecutor.submit(() -> { try{ Pixmap pix = new Pixmap(file.readBytes()); //only bleeds when linear filtering is on at startup if(bleed){ Pixmaps.bleed(pix, 2); } //this returns a *runnable* which actually packs the resulting pixmap; this has to be done synchronously outside the method return () -> { //don't prefix with mod name if it's already prefixed by a category, e.g. `block-modname-content-full`. int hyphen = baseName.indexOf('-'); String fullName = ((prefix && !(hyphen != -1 && baseName.substring(hyphen + 1).startsWith(mod.name + "-"))) ? mod.name + "-" : "") + baseName; packer.add(getPage(file), fullName, new PixmapRegion(pix)); if(textureScale != 1.0f){ textureResize.put(fullName, textureScale); } pix.dispose(); }; }catch(Exception e){ //rethrow exception with details about the cause of failure throw new Exception("Failed to load image " + file + " for mod " + mod.name, e); } })); } } void waitForMain(Runnable run){ CountDownLatch latch = new CountDownLatch(1); Core.app.post(() -> { run.run(); latch.countDown(); }); try{ latch.await(); }catch(InterruptedException e){ throw new RuntimeException(e); } } @Override public void loadSync(){ loadIcons(); } private PageType getPage(Fi file){ String path = file.path(); return path.contains("sprites/blocks/environment") || path.contains("sprites-override/blocks/environment") ? PageType.environment : path.contains("sprites/rubble") || path.contains("sprites-override/rubble") ? PageType.rubble : path.contains("sprites/ui") || path.contains("sprites-override/ui") ? PageType.ui : PageType.main; } /** Removes a mod file and marks it for requiring a restart. */ public void removeMod(LoadedMod mod){ boolean deleted = true; if(mod.loader != null){ if(android){ //Try to remove cache for Android 14 security problem Fi cacheDir = new Fi(Core.files.getCachePath()).child("mods"); Fi modCacheDir = cacheDir.child(mod.file.nameWithoutExtension()); if(modCacheDir.exists()){ deleted = modCacheDir.deleteDirectory(); } }else{ try{ ClassLoaderCloser.close(mod.loader); }catch(Exception e){ Log.err(e); } } } if(mod.root instanceof ZipFi){ mod.root.delete(); } deleted &= mod.file.isDirectory() ? mod.file.deleteDirectory() : mod.file.delete(); if(!deleted){ ui.showErrorMessage("@mod.delete.error"); return; } mods.remove(mod); newImports.remove(mod); mod.dispose(); if(mod.state != ModState.disabled){ requiresReload = true; } } public Scripts getScripts(){ if(scripts == null) scripts = platform.createScripts(); return scripts; } /** @return whether the scripting engine has been initialized. */ public boolean hasScripts(){ return scripts != null; } public boolean requiresReload(){ return requiresReload; } /** @return whether to skip mod loading due to previous initialization failure. */ public boolean skipModLoading(){ return failedToLaunch && Core.settings.getBool("modcrashdisable", true); } /** Loads all mods from the folder, but does not call any methods on them.*/ public void load(){ var candidates = new Seq(); // Add local mods Seq.with(modDirectory.list()) .retainAll(f -> f.extEquals("jar") || f.extEquals("zip") || (f.isDirectory() && Structs.contains(metaFiles, meta -> resolveRoot(f).child(meta).exists()))) .each(candidates::add); // Add Steam workshop mods platform.getWorkshopContent(LoadedMod.class) .each(candidates::add); var mapping = new ObjectMap(); var metas = new Seq(); for(Fi file : candidates){ ModMeta meta = null; try{ meta = findMeta(resolveRoot(file.isDirectory() ? file : new ZipFi(file))); }catch(Throwable ignored){ } if(meta == null || meta.name == null) continue; metas.add(meta); mapping.put(meta.internalName, file); } var resolved = resolveDependencies(metas); for(var entry : resolved){ var file = mapping.get(entry.key); var steam = platform.getWorkshopContent(LoadedMod.class).contains(file); Log.debug("[Mods] Loading mod @", file); try{ LoadedMod mod = loadMod(file, false, entry.value == ModState.enabled); mod.state = entry.value; mods.add(mod); //invalidate ordered mods cache lastOrderedMods = null; if(steam) mod.addSteamID(file.name()); }catch(Throwable e){ if(e instanceof ClassNotFoundException && e.getMessage().contains("mindustry.plugin.Plugin")){ Log.warn("Plugin '@' is outdated and needs to be ported to v7! Update its main class to inherit from 'mindustry.mod.Plugin'.", file.name()); }else if(steam){ Log.err("Failed to load mod workshop file @. Skipping.", file); Log.err(e); }else{ Log.err("Failed to load mod file @. Skipping.", file); Log.err(e); } } } // Resolve the state mods.each(this::updateDependencies); for(var mod : mods){ // Skip mods where the state has already been resolved if(mod.state != ModState.enabled) continue; if(!mod.isSupported()){ mod.state = ModState.unsupported; }else if(!mod.shouldBeEnabled()){ mod.state = ModState.disabled; } } sortMods(); buildFiles(); } private void sortMods(){ //sort mods to make sure servers handle them properly and they appear correctly in the dialog mods.sort(Structs.comps(Structs.comparingInt(m -> m.state.ordinal()), Structs.comparing(m -> m.name))); } private void updateDependencies(LoadedMod mod){ mod.dependencies.clear(); mod.missingDependencies.clear(); mod.missingSoftDependencies.clear(); mod.dependencies = mod.meta.dependencies.map(this::locateMod); mod.softDependencies = mod.meta.softDependencies.map(this::locateMod); for(int i = 0; i < mod.dependencies.size; i++){ if(mod.dependencies.get(i) == null){ mod.missingDependencies.add(mod.meta.dependencies.get(i)); } } for(int i = 0; i < mod.softDependencies.size; i++){ if(mod.softDependencies.get(i) == null){ mod.missingSoftDependencies.add(mod.meta.softDependencies.get(i)); } } } /** @return mods ordered in the correct way needed for dependencies. */ public Seq orderedMods(){ //update cache if it's "dirty"/empty if(lastOrderedMods == null){ //only enabled mods participate; this state is resolved in load() Seq enabled = mods.select(LoadedMod::enabled); var mapping = enabled.asMap(m -> m.meta.internalName); lastOrderedMods = resolveDependencies(enabled.map(m -> m.meta)).orderedKeys().map(mapping::get); } return lastOrderedMods; } public LoadedMod locateMod(String name){ return mods.find(mod -> mod.enabled() && mod.name.equals(name)); } private void buildFiles(){ for(LoadedMod mod : orderedMods()){ boolean zipFolder = !mod.file.isDirectory() && mod.root.parent() != null; String parentName = zipFolder ? mod.root.name() : null; for(Fi file : mod.root.list()){ //ignore special folders like bundles or sprites if(file.isDirectory() && !specialFolders.contains(file.name())){ file.walk(f -> tree.addFile(mod.file.isDirectory() ? f.path().substring(1 + mod.file.path().length()) : zipFolder ? f.path().substring(parentName.length() + 1) : f.path(), f)); } } //load up bundles. Fi folder = mod.root.child("bundles"); if(folder.exists()){ for(Fi file : folder.list()){ if(file.name().startsWith("bundle") && file.extension().equals("properties")){ String name = file.nameWithoutExtension(); bundles.get(name, Seq::new).add(file); } } } } Events.fire(new FileTreeInitEvent()); //add new keys to each bundle I18NBundle bundle = Core.bundle; while(bundle != null){ String str = bundle.getLocale().toString(); String locale = "bundle" + (str.isEmpty() ? "" : "_" + str); for(Fi file : bundles.get(locale, Seq::new)){ try{ PropertiesUtils.load(bundle.getProperties(), file.reader()); }catch(Throwable e){ Log.err("Error loading bundle: " + file + "/" + locale, e); } } bundle = bundle.getParent(); } } /** Check all warnings related to content and show relevant dialogs. Client only. */ //TODO move to another class, Mods.java should not handle UI private void checkWarnings(){ //show 'scripts have errored' info if(scripts != null && scripts.hasErrored()){ ui.showErrorMessage("@mod.scripts.disable"); } //show list of errored content if(mods.contains(LoadedMod::hasContentErrors)){ ui.loadfrag.hide(); new Dialog(""){{ setFillParent(true); cont.margin(15); cont.add("@error.title"); cont.row(); cont.image().width(300f).pad(2).colspan(2).height(4f).color(Color.scarlet); cont.row(); cont.add("@mod.errors").wrap().growX().center().labelAlign(Align.center); cont.row(); cont.pane(p -> { mods.each(m -> m.enabled() && m.hasContentErrors(), m -> { p.add(m.name).color(Pal.accent).left(); p.row(); p.image().fillX().pad(4).color(Pal.accent); p.row(); p.table(d -> { d.left().marginLeft(15f); for(Content c : m.erroredContent){ d.add(c.minfo.sourceFile.nameWithoutExtension()).left().padRight(10); d.button("@details", Icon.downOpen, Styles.cleart, () -> { new Dialog(""){{ setFillParent(true); cont.pane(e -> e.add(c.minfo.error).wrap().grow().labelAlign(Align.center, Align.left)).grow(); cont.row(); cont.button("@ok", Icon.left, this::hide).size(240f, 60f); }}.show(); }).size(190f, 50f).left().marginLeft(6); d.row(); } }).left(); p.row(); }); }); cont.row(); cont.button("@ok", this::hide).size(300, 50); }}.show(); } //show list of missing dependencies Seq toCheck = mods.select(mod -> mod.shouldBeEnabled() && mod.hasUnmetDependencies()); if(!toCheck.isEmpty()){ ui.loadfrag.hide(); checkDependencies(toCheck, false); } } /** Assume mods in toCheck are missing dependencies. */ //TODO move to another class, Mods.java should not handle UI private void checkDependencies(Seq toCheck, boolean soft){ new Dialog(""){{ setFillParent(true); cont.margin(15); int span = soft ? 3 : 2; cont.add("@mod.dependencies.error").colspan(span); cont.row(); cont.image().width(300f).colspan(span).pad(2).height(4f).color(Color.scarlet); cont.row(); cont.pane(p -> { toCheck.each(mod -> { p.add(Core.bundle.get("mods.name") + " [accent]" + mod.meta.displayName).wrap().growX().left().labelAlign(Align.left); p.row(); p.table(d -> { mod.missingDependencies.each(dep -> { d.add("[lightgray] > []" + dep).wrap().growX().left().labelAlign(Align.left); d.row(); }); if(soft){ mod.missingSoftDependencies.each(dep -> { d.add("[lightgray] > []" + dep + " [lightgray]" + Core.bundle.get("mod.dependencies.soft")).wrap().growX().left().labelAlign(Align.left); d.row(); }); } }).growX().padBottom(8f).padLeft(8f); p.row(); }); }).fillX().colspan(span); cont.row(); cont.button("@cancel", Icon.cancel, this::hide).size(160, 50); cont.button(soft ? "@mod.dependencies.downloadreq" : "@mod.dependencies.download", Icon.download, () -> { hide(); Seq toImport = new Seq<>(); toCheck.each(mod -> mod.missingDependencies.each(toImport::addUnique)); downloadDependencies(toImport); }).size(160, 50); if(soft){ if(Core.graphics.isPortrait()){ cont.row(); } cont.button("@mod.dependencies.downloadall", Icon.download, () -> { hide(); Seq toImport = new Seq<>(); toCheck.each(mod -> mod.missingDependencies.each(toImport::addUnique)); toCheck.each(mod -> mod.missingSoftDependencies.each(toImport::addUnique)); downloadDependencies(toImport); }).size(160, 50); } }}.show(); } private void downloadDependencies(Seq toImport){ Seq remaining = toImport.copy(); ui.mods.importDependencies(remaining, () -> { toImport.removeAll(remaining); if(toImport.any()) requiresReload = true; displayDependencyImportStatus(remaining, toImport); }); } //TODO move to another class, Mods.java should not handle UI private void displayDependencyImportStatus(Seq failed, Seq success){ new Dialog(""){{ setFillParent(true); cont.margin(15); cont.add("@mod.dependencies.status").color(Pal.accent).center(); cont.row(); cont.image().width(300f).pad(2).height(4f).color(Pal.accent); cont.row(); cont.pane(p -> { if(success.any()){ p.add("@mod.dependencies.success").color(Pal.accent).wrap().fillX().left().labelAlign(Align.left); p.row(); p.table(t -> { success.each(d -> { t.add("[accent] > []" + d).wrap().growX().left().labelAlign(Align.left); t.row(); }); }).growX().padBottom(8f).padLeft(8f); p.row(); } if(failed.any()){ p.add("@mod.dependencies.failure").color(Color.scarlet).wrap().fillX().left().labelAlign(Align.left); p.row(); p.table(t -> { failed.each(d -> { t.add("[scarlet] > []" + d).wrap().growX().left().labelAlign(Align.left); t.row(); }); }).growX().padBottom(8f).padLeft(8f); } }).fillX(); cont.row(); if(success.any()){ cont.image().width(300f).pad(2).height(4f).color(Pal.accent); cont.row(); cont.add("@mods.reloadexit").center(); cont.row(); hidden(() -> { Log.info("Exiting to reload mods after dependency auto-import."); Core.app.exit(); }); } cont.button("@ok", this::hide).size(300, 50); closeOnBack(); }}.show(); } public void reload(){ newImports.each(this::updateDependencies); newImports.removeAll(m -> m.missingDependencies.isEmpty() && m.softDependencies.isEmpty()); if(newImports.any()){ checkDependencies(newImports, newImports.contains(m -> m.softDependencies.any())); }else{ ui.showInfoOnHidden("@mods.reloadexit", () -> { Log.info("Exiting to reload mods."); Core.app.exit(); }); } } public boolean hasContentErrors(){ return mods.contains(LoadedMod::hasContentErrors) || (scripts != null && scripts.hasErrored()); } /** This must be run on the main thread! */ public void loadScripts(){ if(skipModCode) return; try{ eachEnabled(mod -> { if(mod.root.child("scripts").exists()){ content.setCurrentMod(mod); //if there's only one script file, use it (for backwards compatibility); if there isn't, use "main.js" Seq allScripts = mod.root.child("scripts").findAll(f -> f.extEquals("js")); Fi main = allScripts.size == 1 ? allScripts.first() : mod.root.child("scripts").child("main.js"); if(main.exists() && !main.isDirectory()){ try{ if(scripts == null){ scripts = platform.createScripts(); } scripts.run(mod, main); }catch(Throwable e){ Core.app.post(() -> { Log.err("Error loading main script @ for mod @.", main.name(), mod.meta.name); Log.err(e); }); } }else{ Core.app.post(() -> Log.err("No main.js found for mod @.", mod.meta.name)); } } }); }finally{ content.setCurrentMod(null); } } /** Creates all the content found in mod files. */ public void loadContent(){ //load class mod content first for(LoadedMod mod : orderedMods()){ //hidden mods can't load content if(mod.main != null && !mod.meta.hidden){ content.setCurrentMod(mod); mod.main.loadContent(); } } content.setCurrentMod(null); class LoadRun implements Comparable{ final ContentType type; final Fi file; final LoadedMod mod; public LoadRun(ContentType type, Fi file, LoadedMod mod){ this.type = type; this.file = file; this.mod = mod; } @Override public int compareTo(LoadRun l){ int mod = this.mod.name.compareTo(l.mod.name); if(mod != 0) return mod; return this.file.name().compareTo(l.file.name()); } } Seq runs = new Seq<>(); for(LoadedMod mod : orderedMods()){ Seq unorderedContent = new Seq<>(); ObjectMap orderedContent = new ObjectMap<>(); String[] contentOrder = mod.meta.contentOrder; ObjectSet orderSet = contentOrder == null ? null : ObjectSet.with(contentOrder); if(mod.root.child("content").exists()){ Fi contentRoot = mod.root.child("content"); for(ContentType type : ContentType.all){ String lower = type.name().toLowerCase(Locale.ROOT); Fi folder = contentRoot.child(lower + (lower.endsWith("s") ? "" : "s")); if(folder.exists()){ for(Fi file : folder.findAll(f -> f.extension().equals("json") || f.extension().equals("hjson"))){ //if this is part of the ordered content, put it aside to be dealt with later if(orderSet != null && orderSet.contains(file.nameWithoutExtension())){ orderedContent.put(file.nameWithoutExtension(), new LoadRun(type, file, mod)); }else{ unorderedContent.add(new LoadRun(type, file, mod)); } } } } } //ordered content will be loaded first, if it exists if(contentOrder != null){ for(String contentName : contentOrder){ LoadRun run = orderedContent.get(contentName); if(run != null){ runs.add(run); }else{ Log.warn("Cannot find content defined in contentOrder: @", contentName); } } } //unordered content is sorted alphabetically per mod runs.addAll(unorderedContent.sort()); } for(LoadRun l : runs){ Content current = content.getLastAdded(); try{ //this binds the content but does not load it entirely Content loaded = parser.parse(l.mod, l.file.nameWithoutExtension(), l.file.readString("UTF-8"), l.file, l.type); Log.debug("[@] Loaded '@'.", l.mod.meta.name, (loaded instanceof UnlockableContent u ? u.localizedName : loaded)); }catch(Throwable e){ if(current != content.getLastAdded() && content.getLastAdded() != null){ parser.markError(content.getLastAdded(), l.mod, l.file, e); }else{ ErrorContent error = new ErrorContent(); parser.markError(error, l.mod, l.file, e); } } } //this finishes parsing content fields parser.finishParsing(); Events.fire(new ModContentLoadEvent()); } public void handleContentError(Content content, Throwable error){ parser.markError(content, error); } /** Adds a listener for parsed JSON objects. */ public void addParseListener(ParseListener hook){ parser.listeners.add(hook); } /** @return a list of mods and versions, in the format name:version. */ public Seq getModStrings(){ return mods.select(l -> !l.meta.hidden && l.enabled()).map(l -> l.name + ":" + l.meta.version); } /** Makes a mod enabled or disabled. shifts it.*/ public void setEnabled(LoadedMod mod, boolean enabled){ if(mod.enabled() != enabled){ Core.settings.put("mod-" + mod.name + "-enabled", enabled); requiresReload = true; mod.state = enabled ? ModState.enabled : ModState.disabled; mods.each(this::updateDependencies); sortMods(); } } /** @return the mods that the client is missing. * The inputted array is changed to contain the extra mods that the client has but the server doesn't.*/ public Seq getIncompatibility(Seq out){ Seq mods = getModStrings(); Seq result = mods.copy(); for(String mod : mods){ if(out.remove(mod)){ result.remove(mod); } } return result; } public Seq list(){ return mods; } /** Iterates through each mod with a main class. */ public void eachClass(Cons cons){ orderedMods().each(p -> p.main != null, p -> contextRun(p, () -> cons.get(p.main))); } /** Iterates through each enabled mod. */ public void eachEnabled(Cons cons){ orderedMods().each(LoadedMod::enabled, cons); } public void contextRun(LoadedMod mod, Runnable run){ try{ run.run(); }catch(Throwable t){ throw new RuntimeException("Error loading mod " + mod.meta.name, t); } } /** Tries to find the config file of a mod/plugin. */ public @Nullable ModMeta findMeta(Fi file){ Fi metaFile = null; for(String name : metaFiles){ if((metaFile = file.child(name)).exists()){ break; } } if(!metaFile.exists()){ return null; } ModMeta meta = json.fromJson(ModMeta.class, Jval.read(metaFile.readString()).toString(Jformat.plain)); meta.cleanup(); return meta; } /** Resolves the loading order of a list mods/plugins using their internal names. */ public OrderedMap resolveDependencies(Seq metas){ var context = new ModResolutionContext(); for(var meta : metas){ Seq dependencies = new Seq<>(); for(var dependency : meta.dependencies){ dependencies.add(new ModDependency(dependency, true)); } for(var dependency : meta.softDependencies){ dependencies.add(new ModDependency(dependency, false)); } context.dependencies.put(meta.internalName, dependencies); } for(var key : context.dependencies.keys()){ if(context.ordered.contains(key)){ continue; } resolve(key, context); context.visited.clear(); } var result = new OrderedMap(); for(var name : context.ordered){ result.put(name, ModState.enabled); } result.putAll(context.invalid); return result; } private boolean resolve(String element, ModResolutionContext context){ context.visited.add(element); for(final var dependency : context.dependencies.get(element)){ // Circular dependencies ? if(context.visited.contains(dependency.name) && !context.ordered.contains(dependency.name)){ context.invalid.put(dependency.name, ModState.circularDependencies); return false; // If dependency present, resolve it, or if it's not required, ignore it }else if(context.dependencies.containsKey(dependency.name)){ if(((!context.ordered.contains(dependency.name) && !resolve(dependency.name, context)) || !Core.settings.getBool("mod-" + dependency.name + "-enabled", true)) && dependency.required){ context.invalid.put(element, ModState.incompleteDependencies); return false; } // The dependency is missing, but if not required, skip }else if(dependency.required){ context.invalid.put(element, ModState.missingDependencies); return false; } } if(!context.ordered.contains(element)){ context.ordered.add(element); } return true; } private Fi resolveRoot(Fi fi){ if(OS.isMac && (!(fi instanceof ZipFi))) fi.child(".DS_Store").delete(); Fi[] files = fi.list(); return files.length == 1 && files[0].isDirectory() ? files[0] : fi; } /** Loads a mod file+meta, but does not add it to the list. * Note that directories can be loaded as mods. */ private LoadedMod loadMod(Fi sourceFile, boolean overwrite, boolean initialize) throws Exception{ Time.mark(); ZipFi rootZip = null; try{ Fi zip = resolveRoot(sourceFile.isDirectory() ? sourceFile : (rootZip = new ZipFi(sourceFile))); ModMeta meta = findMeta(zip); if(meta == null){ Log.warn("Mod @ doesn't have a '[mod/plugin].[h]json' file, skipping.", zip); throw new ModLoadException("Invalid file: No mod.json found."); } String camelized = meta.name.replace(" ", ""); String mainClass = meta.main == null ? camelized.toLowerCase(Locale.ROOT) + "." + camelized + "Mod" : meta.main; String baseName = meta.name.toLowerCase(Locale.ROOT).replace(" ", "-"); var other = mods.find(m -> m.name.equals(baseName)); if(other != null){ //steam mods can't really be deleted, they need to be unsubscribed if(overwrite && !other.hasSteamID()){ //close the classloader for jar mods if(!android){ ClassLoaderCloser.close(other.loader); }else if(other.loader != null){ //Try to remove cache for Android 14 security problem Fi cacheDir = new Fi(Core.files.getCachePath()).child("mods"); Fi modCacheDir = cacheDir.child(other.file.nameWithoutExtension()); modCacheDir.deleteDirectory(); } //close zip file if(other.root instanceof ZipFi){ other.root.delete(); } //delete the old mod directory if(other.file.isDirectory()){ other.file.deleteDirectory(); }else{ other.file.delete(); } //unload mods.remove(other); }else{ throw new ModLoadException("A mod with the name '" + baseName + "' is already imported."); } } ClassLoader loader = null; Mod mainMod; Fi mainFile = zip; if(android){ mainFile = mainFile.child("classes.dex"); }else{ String[] path = (mainClass.replace('.', '/') + ".class").split("/"); for(String str : path){ if(!str.isEmpty()){ mainFile = mainFile.child(str); } } } //make sure the main class exists before loading it; if it doesn't just don't put it there //if the mod is explicitly marked as java, try loading it anyway if( (mainFile.exists() || meta.java) && !skipModLoading() && Core.settings.getBool("mod-" + baseName + "-enabled", true) && Version.isAtLeast(meta.minGameVersion) && (meta.getMinMajor() >= minJavaModGameVersion || headless) && !skipModCode && initialize ){ if(ios){ throw new ModLoadException("Java class mods are not supported on iOS."); } loader = platform.loadJar(sourceFile, mainLoader); mainLoader.addChild(loader); Class main = Class.forName(mainClass, true, loader); //detect mods that incorrectly package mindustry in the jar if((main.getSuperclass().getName().equals("mindustry.mod.Plugin") || main.getSuperclass().getName().equals("mindustry.mod.Mod")) && main.getSuperclass().getClassLoader() != Mod.class.getClassLoader()){ throw new ModLoadException( "This mod/plugin has loaded Mindustry dependencies from its own class loader. " + "You are incorrectly including Mindustry dependencies in the mod JAR - " + "make sure Mindustry is declared as `compileOnly` in Gradle, and that the JAR is created with `runtimeClasspath`!" ); } metas.put(main, meta); mainMod = (Mod)main.getDeclaredConstructor().newInstance(); }else{ mainMod = null; } //all plugins are hidden implicitly if(mainMod instanceof Plugin){ meta.hidden = true; } //disallow putting a description after the version if(meta.version != null){ int line = meta.version.indexOf('\n'); if(line != -1){ meta.version = meta.version.substring(0, line); } } //skip mod loading if it failed if(skipModLoading()){ Core.settings.put("mod-" + baseName + "-enabled", false); } if(!headless && Core.settings.getBool("mod-" + baseName + "-enabled", true)){ Log.info("Loaded mod '@' in @ms", meta.name, Time.elapsed()); } return new LoadedMod(sourceFile, zip, mainMod, loader, meta); }catch(Exception e){ //delete root zip file so it can be closed on windows if(rootZip != null) rootZip.delete(); throw e; } } /** Represents a mod's state. May be a jar file, folder or zip. */ public static class LoadedMod implements Publishable, Disposable{ /** The location of this mod's zip file/folder on the disk. */ public final Fi file; /** The root zip file; points to the contents of this mod. In the case of folders, this is the same as the mod's file. */ public final Fi root; /** The mod's main class; may be null. */ public final @Nullable Mod main; /** Internal mod name. Used for textures. */ public final String name; /** This mod's metadata. */ public final ModMeta meta; /** This mod's dependencies as already-loaded mods. */ public Seq dependencies = new Seq<>(); /** This mod's soft dependencies as already-loaded mods. */ public Seq softDependencies = new Seq<>(); /** All missing required dependencies of this mod as strings. */ public Seq missingDependencies = new Seq<>(); /** All missing soft dependencies of this mod as strings. */ public Seq missingSoftDependencies = new Seq<>(); /** Content with initialization code. */ public ObjectSet erroredContent = new ObjectSet<>(); /** Current state of this mod. */ public ModState state = ModState.enabled; /** Icon texture. Should be disposed. */ public @Nullable Texture iconTexture; /** Class loader for JAR mods. Null if the mod isn't loaded or this isn't a jar mod. */ public @Nullable ClassLoader loader; public LoadedMod(Fi file, Fi root, Mod main, ClassLoader loader, ModMeta meta){ this.root = root; this.file = file; this.loader = loader; this.main = main; this.meta = meta; this.name = meta.name.toLowerCase(Locale.ROOT).replace(" ", "-"); } /** @return whether this is a java class mod. */ public boolean isJava(){ return meta.java || main != null || meta.main != null; } @Nullable public String getRepo(){ return Core.settings.getString("mod-" + name + "-repo", meta.repo); } public void setRepo(String repo){ Core.settings.put("mod-" + name + "-repo", repo); } public boolean enabled(){ return state == ModState.enabled || state == ModState.contentErrors; } public boolean shouldBeEnabled(){ return Core.settings.getBool("mod-" + name + "-enabled", true); } public boolean hasUnmetDependencies(){ return !missingDependencies.isEmpty(); } public boolean hasContentErrors(){ return !erroredContent.isEmpty(); } /** @return whether this mod is supported by the game version */ public boolean isSupported(){ //no unsupported mods on servers if(headless) return true; if(isOutdated() || isBlacklisted()) return false; return Version.isAtLeast(meta.minGameVersion); } /** Some mods are known to cause issues with the game; this detects and returns whether a mod is manually blacklisted. */ public boolean isBlacklisted(){ return blacklistedMods.contains(name); } /** @return whether this mod is outdated, i.e. not compatible with v8. */ public boolean isOutdated(){ return getMinMajor() < (isJava() ? minJavaModGameVersion : minModGameVersion); } public int getMinMajor(){ return meta.getMinMajor(); } @Override public void dispose(){ if(iconTexture != null){ iconTexture.dispose(); iconTexture = null; } } @Override public String getSteamID(){ return Core.settings.getString(name + "-steamid", null); } @Override public void addSteamID(String id){ Core.settings.put(name + "-steamid", id); } @Override public void removeSteamID(){ Core.settings.remove(name + "-steamid"); } @Override public String steamTitle(){ return meta.name; } @Override public String steamDescription(){ return meta.description; } @Override public String steamTag(){ return "mod"; } @Override public Fi createSteamFolder(String id){ return file; } @Override public Fi createSteamPreview(String id){ return file.child("preview.png"); } @Override public boolean prePublish(){ if(!file.isDirectory()){ ui.showErrorMessage("@mod.folder.missing"); return false; } if(!file.child("preview.png").exists()){ ui.showErrorMessage("@mod.preview.missing"); return false; } return true; } @Override public String toString(){ return "LoadedMod{" + "file=" + file + ", root=" + root + ", name='" + name + '\'' + '}'; } } /** Mod metadata information.*/ public static class ModMeta{ /** Name as defined in mod.json. Stripped of colors, but may contain spaces. */ public String name; /** Name without spaces in all lower case. */ public String internalName; /** Minimum game version that this mod requires, e.g. "140.1" */ public String minGameVersion = "0"; public @Nullable String displayName, author, description, subtitle, version, main, repo; public Seq dependencies = Seq.with(); public Seq softDependencies = Seq.with(); /** Hidden mods are only server-side or client-side, and do not support adding new content. */ public boolean hidden; /** If true, this mod should be loaded as a Java class mod. This is technically optional, but highly recommended. */ public boolean java; /** If true, this script mod is compatible with iOS. Only set this to true if you don't use extend()/JavaAdapter. */ public boolean iosCompatible; /** To rescale textures with a different size. Represents the size in pixels of the sprite of a 1x1 block. */ public float texturescale = 1.0f; /** If true, bleeding is skipped and no content icons are generated. */ public boolean pregenerated; /** If set, load the mod content in this order by content names */ public String[] contentOrder; public String shortDescription(){ return Strings.truncate(subtitle == null ? (description == null || description.length() > maxModSubtitleLength ? "" : description) : subtitle, maxModSubtitleLength, "..."); } //removes all colors public void cleanup(){ if(name != null) name = Strings.stripColors(name); if(displayName != null) displayName = Strings.stripColors(displayName); if(displayName == null) displayName = name; if(version == null) version = "0"; if(author != null) author = Strings.stripColors(author); if(description != null) description = Strings.stripColors(description); if(subtitle != null) subtitle = Strings.stripColors(subtitle).replace("\n", ""); if(name != null) internalName = name.toLowerCase(Locale.ROOT).replace(" ", "-"); } public int getMinMajor(){ String ver = minGameVersion == null ? "0" : minGameVersion; int dot = ver.indexOf("."); return dot != -1 ? Strings.parseInt(ver.substring(0, dot), 0) : Strings.parseInt(ver, 0); } @Override public String toString(){ return "ModMeta{" + "name='" + name + '\'' + ", minGameVersion='" + minGameVersion + '\'' + ", displayName='" + displayName + '\'' + ", author='" + author + '\'' + ", description='" + description + '\'' + ", subtitle='" + subtitle + '\'' + ", version='" + version + '\'' + ", main='" + main + '\'' + ", repo='" + repo + '\'' + ", dependencies=" + dependencies + ", softDependencies=" + softDependencies + ", hidden=" + hidden + ", java=" + java + ", texturescale=" + texturescale + ", pregenerated=" + pregenerated + '}'; } } public static class ModLoadException extends RuntimeException{ public ModLoadException(String message){ super(message); } } public enum ModState{ enabled, contentErrors, missingDependencies, incompleteDependencies, circularDependencies, unsupported, disabled, } public static class ModResolutionContext { public final ObjectMap> dependencies = new ObjectMap<>(); public final ObjectSet visited = new ObjectSet<>(); public final OrderedSet ordered = new OrderedSet<>(); public final ObjectMap invalid = new OrderedMap<>(); } public static final class ModDependency{ public final String name; public final boolean required; public ModDependency(String name, boolean required){ this.name = name; this.required = required; } } }