package autismclient.addons; import autismclient.AutismClientAddon; import autismclient.api.ApiVersion; import autismclient.api.AutismAddon; import autismclient.util.AutismConfig; import autismclient.util.AutismNotifications; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import net.fabricmc.loader.api.entrypoint.EntrypointContainer; import net.fabricmc.loader.api.metadata.CustomValue; import net.fabricmc.loader.api.metadata.ModMetadata; import net.fabricmc.loader.api.metadata.Person; import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; public final class AddonManager { private static final int MAX_UI_ADDON_NAME_LENGTH = 22; private static final List LOADED = new ArrayList<>(); private static final Map REPORTS = new LinkedHashMap<>(); private record LoadingContext(String addonId, String addonName) {} private static volatile LoadingContext currentContext = null; private static boolean failuresSurfaced = false; private AddonManager() {} public static void init() { LOADED.clear(); REPORTS.clear(); failuresSurfaced = false; AutismConfig config = AutismConfig.getGlobal(); List accepted = new ArrayList<>(); for (EntrypointContainer container : FabricLoader.getInstance() .getEntrypointContainers("autism", AutismAddon.class)) { ModContainer mod; try { mod = container.getProvider(); } catch (Throwable t) { AutismClientAddon.LOG.error("[Addons] Failed to inspect an addon entrypoint", t); continue; } String modId = mod.getMetadata().getId(); AddonReport report = ensureReport(mod.getMetadata()); if (isDisabledOnRestart(modId)) { report.markDisabled("Disabled in AUTISM config. Enable it and restart to load."); continue; } AutismAddon addon; try { addon = container.getEntrypoint(); } catch (Throwable t) { report.markFailed("construct", "Failed to construct entrypoint: " + shortError(t)); AutismClientAddon.LOG.error("[Addons] Failed to construct addon '{}'", modId, t); continue; } try { int declared = addon.apiVersion(); report.setApiVersion(declared); if (declared <= 0) { report.markFailed("apiVersion", "Invalid API version " + declared + "."); AutismClientAddon.LOG.warn("[Addons] Addon '{}' declared invalid API v{}; skipping it.", modId, declared); continue; } if (declared > ApiVersion.CURRENT) { report.markNeedsNewerApi("Needs API v" + declared + ", host provides v" + ApiVersion.CURRENT + "."); AutismClientAddon.LOG.warn("[Addons] Addon '{}' needs API v{} but this client provides v{}; skipping it.", modId, declared, ApiVersion.CURRENT); continue; } if (declared < ApiVersion.CURRENT) { AutismClientAddon.LOG.info("[Addons] Addon '{}' built against API v{} (current v{}); loading.", modId, declared, ApiVersion.CURRENT); } applyMetadata(addon, mod.getMetadata()); report.copyRuntimeMetadata(addon); accepted.add(new LoadedAddon(modId, addon)); } catch (AbstractMethodError e) { report.markMissingApiVersion("Missing apiVersion() override."); AutismClientAddon.LOG.error("[Addons] Addon '{}' does not declare apiVersion(); skipping it.", modId, e); } catch (Throwable t) { report.markFailed("prepare", "Failed to prepare: " + shortError(t)); AutismClientAddon.LOG.error("[Addons] Error preparing addon '{}'", modId, t); } } List categoryReady = new ArrayList<>(); for (LoadedAddon la : accepted) { if (runPhase(la, "onRegisterCategories", la.addon::onRegisterCategories)) { categoryReady.add(la); } else { invokeUnload(la); rollbackRegistrations(la.modId); } } for (LoadedAddon la : categoryReady) { if (runPhase(la, "onInitialize", la.addon::onInitialize)) { LOADED.add(la.addon); AddonReport report = REPORTS.get(la.modId); if (report != null) report.markLoaded(); } else { invokeUnload(la); rollbackRegistrations(la.modId); } } if (!LOADED.isEmpty()) { AutismClientAddon.LOG.info("[Addons] Loaded {} addon(s).", LOADED.size()); } if (config.disabledAddonIds != null) { for (String id : config.disabledAddonIds) { if (id == null || id.isBlank() || REPORTS.containsKey(id)) continue; AddonReport report = new AddonReport(id.trim(), id.trim(), "", "", 0xFFAA66FF); report.markDisabled("Disabled in AUTISM config, but no matching addon entrypoint was discovered."); REPORTS.put(report.modId(), report); } } } private static boolean runPhase(LoadedAddon la, String phase, Runnable body) { String name = la.addon.name == null || la.addon.name.isBlank() ? la.modId : la.addon.name; currentContext = new LoadingContext(la.modId, name); AddonReport report = REPORTS.get(la.modId); if (report != null) report.setPhase(phase); try { body.run(); return true; } catch (Throwable t) { if (report != null) report.markFailed(phase, "Threw during " + phase + ": " + shortError(t)); AutismClientAddon.LOG.error("[Addons] Addon '{}' threw during {}", la.modId, phase, t); return false; } finally { currentContext = null; } } private static void invokeUnload(LoadedAddon la) { if (la == null || la.addon == null) return; try { la.addon.onUnload(); } catch (Throwable t) { AutismClientAddon.LOG.warn("[Addons] Addon '{}' threw during onUnload", la.modId, t); } } private static void rollbackRegistrations(String addonId) { if (addonId == null || addonId.isBlank()) return; AddonReport report = REPORTS.get(addonId); rollbackStep(addonId, report, "macro actions", () -> autismclient.api.macro.MacroActionRegistry.unregisterAddon(addonId)); rollbackStep(addonId, report, "custom menu adapters", () -> autismclient.api.custommenu.CustomMenuAdapterRegistry.unregisterAddon(addonId)); rollbackStep(addonId, report, "presets", () -> autismclient.api.macro.MacroPresetRegistry.unregisterAddon(addonId)); rollbackStep(addonId, report, "events", () -> autismclient.api.event.AddonEvents.unregisterAddon(addonId)); rollbackStep(addonId, report, "hud", () -> autismclient.api.hud.HudElements.unregisterAddon(addonId)); rollbackStep(addonId, report, "modules", () -> autismclient.modules.ModuleRegistry.unregisterAddonModules(addonId)); rollbackStep(addonId, report, "commands", () -> autismclient.commands.AutismCommands.unregisterAddonCommands(addonId)); if (report != null) report.addRollback("Rolled back partial registrations after load failure."); } private static void rollbackStep(String addonId, AddonReport report, String kind, Runnable body) { try { body.run(); } catch (Throwable t) { String detail = "Failed to rollback " + kind + ": " + shortError(t); if (report != null) report.addRollback(detail); AutismClientAddon.LOG.warn("[Addons] Addon '{}' rollback failed for {}", addonId, kind, t); } } private static AddonReport ensureReport(ModMetadata meta) { String modId = meta.getId(); AddonReport existing = REPORTS.get(modId); if (existing != null) return existing; AddonReport report = new AddonReport( modId, meta.getName() == null || meta.getName().isBlank() ? modId : meta.getName(), meta.getVersion().getFriendlyString(), meta.getAuthors().stream().map(Person::getName).collect(Collectors.joining(", ")), metadataColor(meta, 0xFFAA66FF) ); REPORTS.put(modId, report); return report; } private static void applyMetadata(AutismAddon addon, ModMetadata meta) { addon.name = meta.getName(); addon.authors = meta.getAuthors().stream().map(Person::getName).collect(Collectors.joining(", ")); addon.color = metadataColor(meta, addon.color); } private static int metadataColor(ModMetadata meta, int fallback) { CustomValue color = meta.containsCustomValue("autism:color") ? meta.getCustomValue("autism:color") : null; if (color != null && color.getType() == CustomValue.CvType.STRING) { return parseColor(color.getAsString(), fallback); } return fallback; } private static int parseColor(String raw, int fallback) { try { String[] parts = raw.split(","); if (parts.length != 3) return fallback; int r = clamp(Integer.parseInt(parts[0].trim())); int g = clamp(Integer.parseInt(parts[1].trim())); int b = clamp(Integer.parseInt(parts[2].trim())); return 0xFF000000 | (r << 16) | (g << 8) | b; } catch (Exception e) { return fallback; } } private static int clamp(int v) { return Math.max(0, Math.min(255, v)); } private static String currentId() { LoadingContext ctx = currentContext; return ctx == null ? null : ctx.addonId(); } private static String currentName() { LoadingContext ctx = currentContext; return ctx == null ? null : ctx.addonName(); } public static String currentAddonId() { return currentId(); } public static boolean isLifecycleActive() { String id = currentId(); return id != null && !id.isBlank(); } public static String currentAddonName() { return currentName(); } public static String normalizedAddonName(String name, String fallback) { String value = name == null ? "" : name.replaceAll("\\s+", " ").trim(); if (value.isBlank()) value = fallback == null ? "" : fallback.replaceAll("\\s+", " ").trim(); return value.isBlank() ? "Addon" : value; } public static String compactAddonName(String name, String fallback) { String value = normalizedAddonName(name, fallback); if (value.length() <= MAX_UI_ADDON_NAME_LENGTH) return value; return value.substring(0, Math.max(1, MAX_UI_ADDON_NAME_LENGTH - 3)).trim() + "..."; } public static String scopedId(String rawId) { String raw = rawId == null ? "" : rawId.trim(); if (raw.contains(":")) return raw; String owner = currentId(); if (owner == null || owner.isBlank()) return raw; return owner + ":" + raw; } public static String scopedCategoryLabel(String customLabel) { LoadingContext ctx = currentContext; String id = ctx == null ? null : ctx.addonId(); String name = ctx == null ? null : ctx.addonName(); if (name == null || name.isBlank()) name = id; return compactAddonName(name, id); } public static List loaded() { return Collections.unmodifiableList(LOADED); } public static List reports() { return Collections.unmodifiableList(new ArrayList<>(REPORTS.values())); } public static AddonReport report(String addonId) { return addonId == null ? null : REPORTS.get(addonId); } public static File modsFolder() { return FabricLoader.getInstance().getGameDir().resolve("mods").toFile(); } public static boolean isDisabledOnRestart(String addonId) { if (addonId == null || addonId.isBlank()) return false; AutismConfig config = AutismConfig.getGlobal(); if (config.disabledAddonIds == null) return false; String needle = addonId.trim().toLowerCase(Locale.ROOT); for (String id : config.disabledAddonIds) { if (id != null && needle.equals(id.trim().toLowerCase(Locale.ROOT))) return true; } return false; } public static void setDisabledOnRestart(String addonId, boolean disabled) { if (addonId == null || addonId.isBlank()) return; AutismConfig config = AutismConfig.getGlobal(); if (config.disabledAddonIds == null) config.disabledAddonIds = new ArrayList<>(); String normalized = addonId.trim(); boolean currently = isDisabledOnRestart(normalized); if (disabled && !currently) { config.disabledAddonIds.add(normalized); } else if (!disabled && currently) { config.disabledAddonIds.removeIf(id -> id != null && normalized.equalsIgnoreCase(id.trim())); } config.save(); } public static void recordAcceptedRegistration(String kind, String id) { AddonReport report = REPORTS.get(currentId()); if (report != null) report.recordAccepted(kind, id); } public static void recordRejectedRegistration(String addonId, String kind, String id, String reason) { AddonReport report = REPORTS.get(addonId == null || addonId.isBlank() ? currentId() : addonId); if (report != null) report.recordRejected(kind, id, reason); } public static void recordRuntimeError(String addonId, String detail) { AddonReport report = REPORTS.get(addonId); if (report != null) report.recordRuntimeError(detail); } public static void surfaceFailuresOnJoin() { if (failuresSurfaced) return; int issues = issueCount(); if (issues <= 0) return; failuresSurfaced = true; AutismNotifications.warning(issues + " addon issue(s). Open Addons for details."); } private static int issueCount() { int count = 0; for (AddonReport report : REPORTS.values()) { if (report.status().isIssue()) count++; } return count; } private static String shortError(Throwable t) { if (t == null) return "Unknown error"; String msg = t.getMessage(); return msg == null || msg.isBlank() ? t.getClass().getSimpleName() : t.getClass().getSimpleName() + ": " + msg; } private record LoadedAddon(String modId, AutismAddon addon) {} public enum AddonLoadStatus { LOADED("Loaded", false), DISABLED("Disabled on restart", false), FAILED("Failed", true), NEEDS_NEWER_API("Needs newer API", true), MISSING_API_VERSION("Missing apiVersion", true); private final String label; private final boolean issue; AddonLoadStatus(String label, boolean issue) { this.label = label; this.issue = issue; } public String label() { return label; } public boolean isIssue() { return issue; } } public static final class AddonReport { private final String modId; private String name; private String version; private String authors; private int color; private int apiVersion = -1; private int hostApiVersion = ApiVersion.CURRENT; private AddonLoadStatus status = AddonLoadStatus.FAILED; private String phase = "discovered"; private String failureReason = ""; private final Map accepted = new LinkedHashMap<>(); private final Map rejected = new LinkedHashMap<>(); private final List rejectionDetails = new ArrayList<>(); private final List rollbackDetails = new ArrayList<>(); private int runtimeErrors; private String lastRuntimeError = ""; private AddonReport(String modId, String name, String version, String authors, int color) { this.modId = modId == null ? "" : modId; this.name = name == null || name.isBlank() ? this.modId : name; this.version = version == null ? "" : version; this.authors = authors == null ? "" : authors; this.color = color; } private void copyRuntimeMetadata(AutismAddon addon) { if (addon == null) return; if (addon.name != null && !addon.name.isBlank()) this.name = addon.name; if (addon.authors != null && !addon.authors.isBlank()) this.authors = addon.authors; this.color = addon.color; } private void setApiVersion(int apiVersion) { this.apiVersion = apiVersion; } private void setPhase(String phase) { this.phase = phase == null || phase.isBlank() ? this.phase : phase; } private void markLoaded() { this.status = AddonLoadStatus.LOADED; this.phase = "loaded"; this.failureReason = ""; } private void markDisabled(String reason) { this.status = AddonLoadStatus.DISABLED; this.phase = "disabled"; this.failureReason = reason == null ? "" : reason; } private void markFailed(String phase, String reason) { this.status = AddonLoadStatus.FAILED; this.phase = phase == null || phase.isBlank() ? "failed" : phase; this.failureReason = reason == null ? "" : reason; } private void markNeedsNewerApi(String reason) { this.status = AddonLoadStatus.NEEDS_NEWER_API; this.phase = "apiVersion"; this.failureReason = reason == null ? "" : reason; } private void markMissingApiVersion(String reason) { this.status = AddonLoadStatus.MISSING_API_VERSION; this.phase = "apiVersion"; this.failureReason = reason == null ? "" : reason; } private void recordAccepted(String kind, String id) { String key = kind == null || kind.isBlank() ? "item" : kind; accepted.merge(key, 1, Integer::sum); } private void recordRejected(String kind, String id, String reason) { String key = kind == null || kind.isBlank() ? "item" : kind; rejected.merge(key, 1, Integer::sum); String detail = key + (id == null || id.isBlank() ? "" : " " + id) + ": " + (reason == null || reason.isBlank() ? "rejected" : reason); rejectionDetails.add(detail); } private void addRollback(String detail) { if (detail != null && !detail.isBlank()) rollbackDetails.add(detail); } private void recordRuntimeError(String detail) { runtimeErrors++; lastRuntimeError = detail == null || detail.isBlank() ? "Runtime error" : detail; } public String modId() { return modId; } public String name() { return name; } public String version() { return version; } public String authors() { return authors; } public int color() { return color; } public int apiVersion() { return apiVersion; } public int hostApiVersion() { return hostApiVersion; } public AddonLoadStatus status() { return status; } public String phase() { return phase; } public String failureReason() { return failureReason; } public int runtimeErrors() { return runtimeErrors; } public String lastRuntimeError() { return lastRuntimeError; } public Map accepted() { return Collections.unmodifiableMap(accepted); } public Map rejected() { return Collections.unmodifiableMap(rejected); } public List rejectionDetails() { return Collections.unmodifiableList(rejectionDetails); } public List rollbackDetails() { return Collections.unmodifiableList(rollbackDetails); } public int acceptedTotal() { int total = 0; for (int value : accepted.values()) total += value; return total; } public int rejectedTotal() { int total = 0; for (int value : rejected.values()) total += value; return total; } public String summaryCounts() { if (accepted.isEmpty()) return "No registered extensions"; List parts = new ArrayList<>(); for (Map.Entry entry : accepted.entrySet()) { parts.add(entry.getValue() + " " + entry.getKey()); } return String.join(", ", parts); } public String copyReport() { StringBuilder sb = new StringBuilder(); sb.append("Addon: ").append(name).append('\n'); sb.append("ID: ").append(modId).append('\n'); if (!version.isBlank()) sb.append("Version: ").append(version).append('\n'); if (!authors.isBlank()) sb.append("Authors: ").append(authors).append('\n'); sb.append("Status: ").append(status.label()).append('\n'); sb.append("Phase: ").append(phase).append('\n'); sb.append("API: addon v").append(apiVersion < 0 ? "unknown" : Integer.toString(apiVersion)) .append(", host v").append(hostApiVersion).append('\n'); sb.append("Disabled on restart: ").append(isDisabledOnRestart(modId)).append('\n'); if (!failureReason.isBlank()) sb.append("Reason: ").append(failureReason).append('\n'); sb.append("Accepted: ").append(accepted).append('\n'); if (!rejected.isEmpty()) sb.append("Rejected: ").append(rejected).append('\n'); for (String detail : rejectionDetails) sb.append("- ").append(detail).append('\n'); for (String detail : rollbackDetails) sb.append("- ").append(detail).append('\n'); if (runtimeErrors > 0) { sb.append("Runtime errors: ").append(runtimeErrors).append('\n'); sb.append("Last runtime error: ").append(lastRuntimeError).append('\n'); } return sb.toString(); } } }