Files
Mindustry/core/src/mindustry/mod/Mods.java

1508 lines
57 KiB
Java

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<String> blacklistedMods = ObjectSet.with("ui-lib", "braindustry");
private Json json = new Json();
private @Nullable Scripts scripts;
private ContentParser parser = new ContentParser();
private ObjectMap<String, Seq<Fi>> bundles = new ObjectMap<>();
private ObjectSet<String> specialFolders = ObjectSet.with("bundles", "sprites", "sprites-override", ".git");
/** Ordered mods cache. Set to null to invalidate. */
private @Nullable Seq<LoadedMod> lastOrderedMods = new Seq<>();
private ModClassLoader mainLoader = new ModClassLoader(getClass().getClassLoader());
Seq<LoadedMod> mods = new Seq<>();
private Seq<LoadedMod> newImports = new Seq<>();
private ObjectMap<Class<?>, 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<LoadedMod, Fi> 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<? extends Mod> 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<String>();
int[] totalSprites = {0};
//all packing tasks to await
var tasks = new Seq<Future<Runnable>>();
eachEnabled(mod -> {
Seq<Fi> sprites = mod.root.child("sprites").findAll(f -> f.extension().equals("png"));
Seq<Fi> 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<RegionEntry>[] entries = new Seq[PageType.all.length];
for(int i = 0; i < PageType.all.length; i++){
entries[i] = new Seq<>();
}
ObjectMap<Texture, PageType> 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<Content> 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<Fi> sprites, LoadedMod mod, boolean prefix, Seq<Future<Runnable>> tasks, ObjectFloatMap<String> 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<Fi>();
// 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<String, Fi>();
var metas = new Seq<ModMeta>();
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<LoadedMod> orderedMods(){
//update cache if it's "dirty"/empty
if(lastOrderedMods == null){
//only enabled mods participate; this state is resolved in load()
Seq<LoadedMod> 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<LoadedMod> 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<LoadedMod> 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<String> 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<String> 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<String> toImport){
Seq<String> 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<String> failed, Seq<String> 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<Fi> 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<LoadRun>{
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<LoadRun> runs = new Seq<>();
for(LoadedMod mod : orderedMods()){
Seq<LoadRun> unorderedContent = new Seq<>();
ObjectMap<String, LoadRun> orderedContent = new ObjectMap<>();
String[] contentOrder = mod.meta.contentOrder;
ObjectSet<String> 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<String> 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<String> getIncompatibility(Seq<String> out){
Seq<String> mods = getModStrings();
Seq<String> result = mods.copy();
for(String mod : mods){
if(out.remove(mod)){
result.remove(mod);
}
}
return result;
}
public Seq<LoadedMod> list(){
return mods;
}
/** Iterates through each mod with a main class. */
public void eachClass(Cons<Mod> cons){
orderedMods().each(p -> p.main != null, p -> contextRun(p, () -> cons.get(p.main)));
}
/** Iterates through each enabled mod. */
public void eachEnabled(Cons<LoadedMod> 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<String, ModState> resolveDependencies(Seq<ModMeta> metas){
var context = new ModResolutionContext();
for(var meta : metas){
Seq<ModDependency> 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<String, ModState>();
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<LoadedMod> dependencies = new Seq<>();
/** This mod's soft dependencies as already-loaded mods. */
public Seq<LoadedMod> softDependencies = new Seq<>();
/** All missing required dependencies of this mod as strings. */
public Seq<String> missingDependencies = new Seq<>();
/** All missing soft dependencies of this mod as strings. */
public Seq<String> missingSoftDependencies = new Seq<>();
/** Content with initialization code. */
public ObjectSet<Content> 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<String> dependencies = Seq.with();
public Seq<String> 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<String, Seq<ModDependency>> dependencies = new ObjectMap<>();
public final ObjectSet<String> visited = new ObjectSet<>();
public final OrderedSet<String> ordered = new OrderedSet<>();
public final ObjectMap<String, ModState> 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;
}
}
}