diff --git a/README.md b/README.md index 3f8bad59..d075721c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - Download stable/beta versions at the [AutoPlug-Releases](https://github.com/Osiris-Team/AutoPlug-Releases) repo. - Follow the [AutoPlug-Client-Development](https://bit.ly/acprogress) and see what we are currently working on. +- [Native client dashboard, server browser and launcher](./docs/native-client-dashboard.md), including isolated [MODS/PLUGINS profiles](./docs/unified-profiles.md), CLI commands and verification limits. - Support the development by [donating](https://www.paypal.com/donate?hosted_button_id=JNXQCWF2TF9W4). - Issues or questions? Checkout the [FAQ](/docs/FAQ.md) and/or join our [Discord](https://discord.com/invite/GGNmtCC), for live chat/voice support. diff --git a/docs/native-client-dashboard.md b/docs/native-client-dashboard.md new file mode 100644 index 00000000..6d756637 --- /dev/null +++ b/docs/native-client-dashboard.md @@ -0,0 +1,81 @@ +# Native client dashboard and unified profiles + +This implementation starts from AutoPlug 10.2.0 master and keeps AutoPlug's MIT license. No HMCL or other launcher source is imported. Minecraft and loader artifacts are downloaded from their published distribution endpoints into the end user's cache; they are not bundled with AutoPlug or this contribution. + +## Dashboard + +Click the tray icon to open the resizable FlatLaf dashboard. The application also opens its window when a desktop has no system tray. The five views are Server Browser, Singleplayer, Profiles, Server Manager and Settings. Server Manager retains the existing update/configuration panels, adds console output, and dispatches start, restart, stop, backup, task and server-console commands through the existing server services. Closing the window hides it; disabling/recreating the dashboard releases its console listener, cancels that window's pending account sign-in and releases the world view's refresh timer. + +Server Browser uses the existing `MineStat` JSON status implementation, with a bounded response and read timeout. It displays MOTD, version/protocol, online/max players and status-query latency. Add or edit a server's **local loader choice** explicitly: vanilla status does not expose a server's required mods or loader. Proxy advertisements containing multiple versions require selecting a profile explicitly rather than guessing. + +The dashboard automatically imports vanilla `servers.dat` when the default file exists, without pinging servers or modifying that file. Import adds addresses without overwriting an existing AutoPlug loader choice. Export is optional and user initiated. It replaces the chosen vanilla list, retains unknown root tags and retained servers' icons/settings, checks for changes during preparation and saves a uniquely named backup first. Close Minecraft before exporting to avoid concurrent edits by the game. Default paths cover Windows AppData, macOS Application Support and Linux `.minecraft`. NBT supports all standard tag types, raw and gzip streams, with byte, nesting and collection limits. Unicode strings use Java's NBT modified UTF encoding. + +Profiles supports client MODS, server PLUGINS and server MODS_SERVER collections. Create, clone/migrate, delete, mark templates, select default update profiles, check/apply updates and launch client profiles. Each profile lives in `~/.autoplug/instances/`; mutable configuration is copied, while JARs use the shared checksum cache. Server profiles cannot be launched as Minecraft clients. See [unified-profiles.md](unified-profiles.md) for update sources, explicit source identities, transaction/recovery behavior and provider limits. + +Singleplayer creates virtual worlds from server profiles, with independent save/config directories, a thumbnail and play/share actions. After an explicit per-world Minecraft EULA choice, it prepares an official Paper/Purpur/Fabric server (or an explicitly supplied matching executable JAR), waits for its MineStat status ping, and launches a matching client at the local port. Closing the client stops its server gracefully. Server-mod worlds require an explicitly matching client profile rather than copying server-only mods into the client. See [the world protocol and lifecycle documentation](../src/main/java/com/osiris/autoplug/client/worlds/PROTOCOL.md) for supported server installation paths and cleanup behavior. + +Play & share is an explicit request for a temporary TCP UPnP lease; ordinary local play binds only to loopback. Copy join address reads an existing mapping and never opens a port itself. Router discovery, SOAP control, finite-lease renewal and ownership checks are implemented without new dependencies. Existing mappings are not overwritten, and only an owned mapping can be deleted. A discovery, public-IP/CGNAT or mapping failure retains local play and gives forwarding/tunnel guidance. Lease renewal and explicit Stop cleanup failures remain visible. No real router was configured during contributor tests. + +Play first checks the saved server. It selects a matching non-template client profile by exact Minecraft version and the saved local loader. Otherwise it requests confirmation to clone an appropriate client template, resolves releases for the target version using AutoPlug's update services, shows the complete plan, and applies it only after confirmation. Files with no compatible release become `.jar.disabled`. Network/provider errors stop the operation. A declined or failed new clone is removed; the source template remains intact. + +Settings selects the default template, optional Java executables, offline/Microsoft account mode, a preferred world port (0 selects an available port) and whether explicit sharing may use UPnP. Invalid preferences do not partially change an account or persisted settings. When no Java path is configured, the launcher selects the major declared in Mojang metadata using AutoPlug's Adoptium API protocol and SHA-256 checked runtime downloads. Overrides are checked with `java -version` before use, including when an installer needs Java. Java 8, 17, 21 and the current Minecraft 26.3 Java 25 requirement are supported. + +## Authentication + +Offline mode is for local testing and offline-capable servers. It does not provide authentication to online-mode servers. Microsoft mode uses an end-user-initiated OAuth device flow, Xbox Live, XSTS, Minecraft entitlement and profile checks. Configure your own registered Microsoft public-client application ID; Minecraft Services may require application approval. The UI displays the verification URI and user code so the account owner can sign in in their browser. No password is collected. Account access and refresh tokens remain in memory; no token appears in preferences or command logs. Sign-out invalidates pending sign-in attempts. Restarting AutoPlug requires signing in again. + +## CLI + +The same commands work in the running AutoPlug console and as one-shot startup arguments. Client/profile-only startup commands return without starting the server, recurring tasks, SSH or the online console. Periods in versions and hostnames are preserved. Separate multiple commands by another dot-prefixed command token. + +```text +java -jar AutoPlug-Client.jar ".profiles create survival 1.20.4 fabric" +java -jar AutoPlug-Client.jar ".profiles create hub 1.20.4 paper --type plugins" +java -jar AutoPlug-Client.jar ".profiles create servermods 1.20.4 fabric --type mods-server" +java -jar AutoPlug-Client.jar ".profiles list --type mods" +java -jar AutoPlug-Client.jar ".profiles activate survival" +java -jar AutoPlug-Client.jar ".check mods" +java -jar AutoPlug-Client.jar ".update mods --profile survival --confirm-disabling" +java -jar AutoPlug-Client.jar ".profiles clone survival 1.21.1 --confirm-disabling" +java -jar AutoPlug-Client.jar ".client launch survival --server play.example.org:25566" +java -jar AutoPlug-Client.jar ".client servers list" +java -jar AutoPlug-Client.jar ".mc worlds create Workshop hub" +java -jar AutoPlug-Client.jar ".mc worlds list" +# After reviewing the Minecraft EULA, explicitly accept for this world: +java -jar AutoPlug-Client.jar ".mc worlds accept-eula workshop" +java -jar AutoPlug-Client.jar ".mc worlds launch workshop" +# Sharing additionally requires online Microsoft authentication in the same process: +.mc worlds launch workshop --share +``` + +Default MODS, PLUGINS and MODS_SERVER update profiles are selected independently using `.profiles activate ` or the profile view's default-updates button. `.check/.update mods|plugins|mods-server [--profile ]` uses that type's active profile when the flag is omitted. Without an active profile, interactive `.check mods/plugins` retains the existing current-directory server behavior; one-shot checks, server-mod checks and `.update` request an explicit profile selection. They do not invent or mutate a current server profile. Deleting an active profile clears its default selection. + +Additional commands: `.profiles template true|false`, `.profiles delete `, `.client servers import|export`, `.client auth offline `, `.client auth microsoft ` and `.client auth signout`. Preferences persist but authentication sessions do not. A one-shot Microsoft auth command ends its session on process exit; use the running console/UI to sign in and then launch in the same process, or supply both commands in one invocation. + +## Launch protocols and verification + +Mojang version manifests determine client, libraries, assets, native libraries, logging config and Java major. Fabric/Quilt use official loader profile APIs. Forge/NeoForge use their official installers, embedded Maven artifacts and declared client processors, with processor output checks and separate installer working directories. The native engine handles OS/rules, argument substitution and classpaths without using a shell. Direct join uses legacy `--server/--port` before Minecraft 1.20 and the official `--quickPlayMultiplayer` flow from 1.20 onward (introduced in 23w14a). + +The contribution contains comprehensive unit/fixture tests plus explicit opt-in public-API probes. `OfflineClientLaunchTest` prepares and starts an original compiled fixture client through the actual `NativeLauncher` and `ProcessBuilder` paths. World lifecycle tests start original dedicated-server/client child JVMs and use the existing MineStat wire protocol. UPnP tests exercise SOAP against an original localhost HTTP fixture, including mapping ownership, renewal and cleanup. These prove the execution paths without claiming that Minecraft was launched or a real router was mapped. `DashboardLayoutTest` renders the actual four client Swing panels at two sizes offscreen; it excludes the server-manager view and is not native desktop/tray acceptance. Real Forge/NeoForge metadata is checked, but fixture processors execute original test code. The actual installers have not been run against the real Minecraft JAR. Final Microsoft-authenticated game joining, public-world connectivity and desktop acceptance require the maintainers' real game/router environment. + +Run the deterministic contribution suites on a JDK with Maven: + +```text +mvn test -Djava.awt.headless=true -Dtest=NativeLauncherTest,MicrosoftOAuthTest,LoaderInstallerTest,ManagedJavaRuntimeTest,OfflineClientLaunchTest,LauncherCancellationTest,UnifiedProfilesTest,ProfileProviderTest,ServerModProfilesTest,ServerBrowserTest,NativeClientServiceTest,DashboardLayoutTest,WorldStoreTest,WorldServerPreparerTest,VirtualWorldServiceTest,UpnpPortMapperTest,UtilsEnvironmentTest,UtilsStringTest,UtilsFileTest,UtilsMapTest +``` + +The inherited repository also has live-network/download tests (Google reachability, SteamCMD and server/provider downloads). The command above selects the deterministic contribution and related utility suites explicitly rather than claiming those external integrations were exercised offline. Public metadata probes are separate opt-in programs and never use an owner account. + +Primary protocol references: + +- [Official Mojang manifest](https://piston-meta.mojang.com/mc/game/version_manifest_v2.json) +- [Minecraft 23w14a Quick Play changes](https://www.minecraft.net/en-us/article/minecraft-snapshot-23w14a) +- [Fabric Meta](https://meta.fabricmc.net/) +- [Quilt Meta](https://meta.quiltmc.org/) +- [NeoForge version format and Java 25](https://neoforged.net/news/26.1release/) +- [Microsoft device authorization](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code) +- [NBT author's format/reader reference](https://github.com/Querz/NBT) +- [UPnP WANIPConnection service](https://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v1-Service.pdf) +- [UPnP device discovery architecture](https://openconnectivity.org/upnp-specs/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf) + +Fixture metadata sources and published archive hashes are recorded in `src/test/resources/native-launcher/sources.json` and `src/test/resources/virtual-worlds/sources.json`. New implementation and verification were performed by AI coding agents, including direct source review and actual test execution; no separate human technical review or live account test is claimed. diff --git a/docs/unified-profiles.md b/docs/unified-profiles.md new file mode 100644 index 00000000..fed44d08 --- /dev/null +++ b/docs/unified-profiles.md @@ -0,0 +1,75 @@ +# Unified client/server profiles + +Profiles are stored under `~/.autoplug/profiles` with independent instances under +`~/.autoplug/instances/`. A profile records its id, `MODS` (client), `PLUGINS` +(server plugins), or `MODS_SERVER` (server mods) type, game version, loader and +template status. Client and server mods live in the instance's `mods` directory; +plugins live in `plugins`. Each of the three types has its own independent active +default for update commands. `mods-server` and `mods_server` are supported CLI +aliases for `MODS_SERVER`. Changes never replace the global server updater +configuration or change the process working directory. + +Examples: + +```text +.profiles create fabric-base 1.20.1 fabric --template +.profiles create paper-base 1.20.1 paper --type plugins --template +.profiles create fabric-server 1.20.1 fabric --type mods-server +.profiles activate fabric-server +.check mods-server +.update mods-server +.profiles list --type plugins +.profiles clone fabric-base 1.21.1 fabric-new +.check mods --profile fabric-new +.update mods --profile fabric-new +.profiles template fabric-new true +.profiles delete fabric-new +``` + +Clone copies mutable settings and imports jars into the shared content-addressed +cache. It then checks the target release set, including downgrades, required +dependencies and incompatible projects. Unavailable/unknown artifacts appear in +the review summary. CLI migration that would disable them requires +`--confirm-disabling`; without it, an unsuccessful new clone is removed and the +source is preserved. The dashboard presents the same review before applying. + +Network or malformed-response failures are errors, not compatibility results. +They stop the entire update and preserve the instance. Artifacts are staged and +their provider hashes and jar format verified before replacement. Original +hashes are checked before and after staging. Per-profile locks, original-file +backups and a journal support rollback and recovery of an interrupted prepared +transaction. Names and paths are checked before installing or deleting files. + +Modrinth lookup identifies exact local bytes and resolves releases for the +explicit Minecraft version and loader. Required dependencies are included and +pinned dependency versions are validated. Plugin loader compatibility is +explicit: Bukkit and Spigot profiles do not accept Paper-only artifacts. + +Explicit provider project IDs can be recorded in the instance's +`asset-sources.properties` when an artifact is not identifiable by Modrinth hash: + +```properties +some-mod.jar.modrinth=project-id-or-slug +another-mod.jar.curseforge=12345 +plugin.jar.spigot=12345 +worldedit.jar.bukkit=31043 +``` + +Successful migrations preserve provider identities under their new filenames. +CurseForge resolution reuses AutoPlug's configured API client; unavailable keys, +missing authorized download URLs or transport errors stop the migration. +CurseForge loader/game filters, pagination and declared file metadata are checked. +Bukkit files are selected by declared Minecraft version and their supplied MD5 +is verified. Spiget only exposes tested versions for its current resource; the +updater does not invent historical version compatibility. If that current +resource does not declare support, the artifact needs explicit disabling or a +separately configured compatible project. + +`UnifiedProfilesTest` exercises isolation, clone migration, explicit disabling, +re-enabling, dependency installation, collisions, changed files, failed downloads, +checksums, cache corruption and transaction recovery. `ProfileProviderTest` +uses real local HTTP sockets for transport failures and exercises target-loader +selection and resource-service reuse. `ProfileProviderLiveSmoke` is an opt-in +main class for actual public Modrinth/Bukkit metadata GETs. It downloads no jars, +uses no account and launches no Minecraft process. Live Microsoft-authenticated +launch/join acceptance remains with maintainers, as specified for the bounty. diff --git a/pom.xml b/pom.xml index f0521ae4..ec621223 100644 --- a/pom.xml +++ b/pom.xml @@ -346,6 +346,11 @@ AUTO-GENERATED FILE, CHANGES SHOULD BE DONE IN ./JPM.java or ./src/main/java//JP + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.0 + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/main/java/JPM.java b/src/main/java/JPM.java index 499a666a..5361b51e 100644 --- a/src/main/java/JPM.java +++ b/src/main/java/JPM.java @@ -110,6 +110,8 @@ public ThisProject(List args) throws Exception { // Add custom plugins plugins.add(new AutoPlugPropertiesPlugin()); + // Explicit Jupiter provider: Maven's inherited Surefire version may silently skip JUnit 5. + plugins.add(new JPM.Plugin("org.apache.maven.plugins", "maven-surefire-plugin", "3.6.0")); // Execute build File cwd = new File(System.getProperty("user.dir")); diff --git a/src/main/java/com/osiris/autoplug/client/Main.java b/src/main/java/com/osiris/autoplug/client/Main.java index 4ea6f1fb..9b999af1 100644 --- a/src/main/java/com/osiris/autoplug/client/Main.java +++ b/src/main/java/com/osiris/autoplug/client/Main.java @@ -71,6 +71,7 @@ public static void main(String[] _args) { List args = new ArrayList<>(); if (_args != null) Collections.addAll(args, _args); + if (com.osiris.autoplug.client.clientapp.StartupCommands.executeClientOnly(args, System.out::println)) return; // Check various things to ensure an fully functioning application. // If one of these checks fails this application is stopped. long now = System.currentTimeMillis(); @@ -268,8 +269,8 @@ public static void main(String[] _args) { try { if (generalConfig.autoplug_system_tray.asBoolean()) { now = System.currentTimeMillis(); - new MainWindow(generalConfig); - AL.info("Started system-tray GUI, took " + (System.currentTimeMillis() - now) + "ms"); + MainWindow.open(generalConfig); + AL.info("Scheduled client dashboard on the Swing event thread"); } } catch (Exception e) { AL.warn(e); @@ -312,16 +313,7 @@ public static void main(String[] _args) { Server.start(); // Execute arguments as commands if existing - String argsString = ""; - for (String arg : args) { - argsString += arg + " "; - } - if (argsString.contains(".")) { - String[] commands = argsString.split("\\."); // Split by dots - for (String c : commands) { - Commands.execute("." + c); - } - } + for (String startupCommand : com.osiris.autoplug.client.clientapp.StartupCommands.parse(args)) Commands.execute(startupCommand); // We have to keep this main Thread running. // If we don't, the NonBlockingPipedInputStream stops working diff --git a/src/main/java/com/osiris/autoplug/client/browser/ClientDataFiles.java b/src/main/java/com/osiris/autoplug/client/browser/ClientDataFiles.java new file mode 100644 index 00000000..724fd1db --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ClientDataFiles.java @@ -0,0 +1,23 @@ +package com.osiris.autoplug.client.browser; + +import java.io.IOException; +import java.nio.file.*; + +public final class ClientDataFiles { + private ClientDataFiles() { } + public static void rejectLinks(Path path) throws IOException { + Path absolute = path.toAbsolutePath().normalize(); + for (Path current = absolute; current != null; current = current.getParent()) { + if (Files.isSymbolicLink(current)) throw new IOException("Client data cannot use linked paths"); + } + } + public static void atomicWrite(Path path, byte[] contents) throws IOException { + rejectLinks(path); Files.createDirectories(path.toAbsolutePath().getParent()); + Path temp = Files.createTempFile(path.toAbsolutePath().getParent(), ".autoplug-", ".part"); + try { + Files.write(temp, contents); + try { Files.move(temp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temp); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/MinecraftServersDat.java b/src/main/java/com/osiris/autoplug/client/browser/MinecraftServersDat.java new file mode 100644 index 00000000..d1c44d2c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/MinecraftServersDat.java @@ -0,0 +1,142 @@ +package com.osiris.autoplug.client.browser; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; + +/** Small bounded NBT reader/writer. Preserves unknown root and per-server tags during export. */ +public final class MinecraftServersDat { + private static final int MAX_BYTES = 32 * 1024 * 1024, MAX_ITEMS = 100_000, MAX_DEPTH = 64; + public static final class Tag { + public final int type; public final Object value; + public Tag(int type, Object value) { this.type = type; this.value = value; } + } + public static final class ListValue { + public final int elementType; public final List values; + public ListValue(int elementType, List values) { this.elementType = elementType; this.values = values; } + } + public static final class Document { + public final String name; public final Tag root; public final boolean compressed; + public Document(String name, Tag root, boolean compressed) { this.name = name; this.root = root; this.compressed = compressed; } + public List servers() throws IOException { + Map values = compound(root); + Tag servers = values.get("servers"); + if (servers == null) return Collections.emptyList(); + if (servers.type != 9 || ((ListValue)servers.value).elementType != 10) throw new IOException("Invalid servers list"); + List result = new ArrayList<>(); + for (Tag item : ((ListValue)servers.value).values) { + Map entry = compound(item); + try { result.add(new SavedServer(string(entry.get("name")), ServerAddress.parse(string(entry.get("ip"))), "VANILLA")); } + catch (IllegalArgumentException error) { throw new IOException("Invalid saved server", error); } + } + return result; + } + public Document withServers(List replacements) throws IOException { + Map values = new LinkedHashMap<>(compound(root)); + Map previous = new LinkedHashMap<>(); + Tag old = values.get("servers"); + if (old != null) { + servers(); // Validate before replacing, so malformed data is not silently discarded. + for (Tag item : ((ListValue)old.value).values) { + Map entry = compound(item); + previous.put(ServerAddress.parse(string(entry.get("ip"))), item); + } + } + List entries = new ArrayList<>(); + for (SavedServer server : replacements) { + Tag retained = previous.get(server.address); + Map entry = retained == null ? new LinkedHashMap<>() : new LinkedHashMap<>(compound(retained)); + entry.put("name", new Tag(8, server.name)); entry.put("ip", new Tag(8, server.address.toString())); + entries.add(new Tag(10, entry)); + } + values.put("servers", new Tag(9, new ListValue(10, entries))); + return new Document(name, new Tag(10, values), compressed); + } + } + public static Document empty() { return new Document("", new Tag(10, new LinkedHashMap()), false); } + public static Document read(Path file) throws IOException { + if (Files.isSymbolicLink(file) || !Files.isRegularFile(file) || Files.size(file) > MAX_BYTES) throw new IOException("Invalid or oversized servers.dat"); + try (InputStream input = Files.newInputStream(file)) { return read(input); } + } + public static Document read(InputStream input) throws IOException { + PushbackInputStream source = new PushbackInputStream(input, 2); + int first = source.read(), second = source.read(); + if (second >= 0) source.unread(second); if (first >= 0) source.unread(first); + boolean gzip = first == 0x1f && second == 0x8b; + InputStream payload = gzip ? new GZIPInputStream(source) : source; + try (DataInputStream data = new DataInputStream(new LimitedInput(payload))) { + int type = data.readUnsignedByte(); + if (type != 10) throw new IOException("servers.dat root must be an NBT compound"); + String name = data.readUTF(); + Tag root = readTag(data, type, 0); + if (data.read() != -1) throw new IOException("Trailing NBT data"); + return new Document(name, root, gzip); + } + } + private static Tag readTag(DataInputStream input, int type, int depth) throws IOException { + if (depth > MAX_DEPTH) throw new IOException("NBT nesting limit exceeded"); + switch (type) { + case 1: return new Tag(type, input.readByte()); + case 2: return new Tag(type, input.readShort()); + case 3: return new Tag(type, input.readInt()); + case 4: return new Tag(type, input.readLong()); + case 5: return new Tag(type, input.readFloat()); + case 6: return new Tag(type, input.readDouble()); + case 7: { byte[] bytes = new byte[count(input, MAX_BYTES)]; input.readFully(bytes); return new Tag(type, bytes); } + case 8: return new Tag(type, input.readUTF()); + case 9: { + int childType = input.readUnsignedByte(), size = count(input, MAX_ITEMS); + if (childType > 12 || (childType == 0 && size != 0)) throw new IOException("Invalid NBT list type"); + List values = new ArrayList<>(); + for (int i = 0; i < size; i++) values.add(readTag(input, childType, depth + 1)); + return new Tag(type, new ListValue(childType, values)); + } + case 10: { + Map values = new LinkedHashMap<>(); + int child; + while ((child = input.readUnsignedByte()) != 0) { + if (values.size() >= MAX_ITEMS) throw new IOException("NBT compound limit exceeded"); + String key = input.readUTF(); + if (values.containsKey(key)) throw new IOException("Duplicate NBT tag"); + values.put(key, readTag(input, child, depth + 1)); + } + return new Tag(type, values); + } + case 11: { int[] values = new int[count(input, MAX_BYTES / 4)]; for (int i = 0; i < values.length; i++) values[i] = input.readInt(); return new Tag(type, values); } + case 12: { long[] values = new long[count(input, MAX_BYTES / 8)]; for (int i = 0; i < values.length; i++) values[i] = input.readLong(); return new Tag(type, values); } + default: throw new IOException("Unsupported NBT type " + type); + } + } + private static int count(DataInputStream input, int limit) throws IOException { int size = input.readInt(); if (size < 0 || size > limit) throw new IOException("Invalid NBT length"); return size; } + @SuppressWarnings("unchecked") private static Map compound(Tag tag) throws IOException { if (tag == null || tag.type != 10) throw new IOException("Expected compound"); return (Map)tag.value; } + private static String string(Tag tag) throws IOException { if (tag == null || tag.type != 8) throw new IOException("Expected string"); return (String)tag.value; } + public static void write(Document document, OutputStream output) throws IOException { + OutputStream payload = document.compressed ? new GZIPOutputStream(output) : output; + try (DataOutputStream data = new DataOutputStream(payload)) { data.writeByte(10); data.writeUTF(document.name); writeTag(data, document.root, 0); } + } + private static void writeTag(DataOutputStream output, Tag tag, int depth) throws IOException { + if (depth > MAX_DEPTH) throw new IOException("NBT nesting limit exceeded"); + switch (tag.type) { + case 1: output.writeByte(((Number)tag.value).byteValue()); break; + case 2: output.writeShort(((Number)tag.value).shortValue()); break; + case 3: output.writeInt(((Number)tag.value).intValue()); break; + case 4: output.writeLong(((Number)tag.value).longValue()); break; + case 5: output.writeFloat(((Number)tag.value).floatValue()); break; + case 6: output.writeDouble(((Number)tag.value).doubleValue()); break; + case 7: { byte[] bytes = (byte[])tag.value; output.writeInt(bytes.length); output.write(bytes); break; } + case 8: output.writeUTF((String)tag.value); break; + case 9: { ListValue list = (ListValue)tag.value; output.writeByte(list.elementType); output.writeInt(list.values.size()); for (Tag child : list.values) { if (child.type != list.elementType) throw new IOException("Mixed NBT list"); writeTag(output, child, depth + 1); } break; } + case 10: { for (Map.Entry entry : compound(tag).entrySet()) { output.writeByte(entry.getValue().type); output.writeUTF(entry.getKey()); writeTag(output, entry.getValue(), depth + 1); } output.writeByte(0); break; } + case 11: { int[] values = (int[])tag.value; output.writeInt(values.length); for (int value : values) output.writeInt(value); break; } + case 12: { long[] values = (long[])tag.value; output.writeInt(values.length); for (long value : values) output.writeLong(value); break; } + default: throw new IOException("Unsupported NBT type"); + } + } + private static final class LimitedInput extends FilterInputStream { + private int remaining = MAX_BYTES; + LimitedInput(InputStream source) { super(source); } + @Override public int read() throws IOException { int result = in.read(); if (result >= 0 && --remaining < 0) throw new IOException("NBT size limit exceeded"); return result; } + @Override public int read(byte[] bytes, int offset, int size) throws IOException { if (size == 0) return 0; int count = in.read(bytes, offset, Math.min(size, Math.max(1, remaining))); if (count > 0 && (remaining -= count) < 0) throw new IOException("NBT size limit exceeded"); return count; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java b/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java new file mode 100644 index 00000000..74f0e9e1 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java @@ -0,0 +1,18 @@ +package com.osiris.autoplug.client.browser; + +import java.util.Locale; +import java.util.Objects; + +public final class SavedServer { + public final String name; + public final ServerAddress address; + /** Standard server status does not identify a loader; this is the user's local choice. */ + public final String loaderHint; + public SavedServer(String name, ServerAddress address, String loaderHint) { + if (name == null || name.trim().isEmpty() || name.length() > 255 || name.indexOf('\0') >= 0) throw new IllegalArgumentException("Invalid server name"); + String loader = loaderHint == null ? "VANILLA" : loaderHint.toUpperCase(Locale.ROOT); + if (!loader.matches("VANILLA|FABRIC|QUILT|FORGE|NEOFORGE")) throw new IllegalArgumentException("Choose a supported client loader"); + this.name = name.trim(); this.address = Objects.requireNonNull(address); this.loaderHint = loader; + } + @Override public String toString() { return name + " — " + address + " (" + loaderHint + ")"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java b/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java new file mode 100644 index 00000000..e3a777f7 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java @@ -0,0 +1,41 @@ +package com.osiris.autoplug.client.browser; + +import java.util.Locale; + +/** A Minecraft address, never a URL, command line, or inferred mod-loader hint. */ +public final class ServerAddress { + public final String host; + public final int port; + private ServerAddress(String host, int port) { this.host = host; this.port = port; } + public static ServerAddress parse(String input) { + if (input == null) throw new IllegalArgumentException("Enter a server address"); + String value = input.trim(), host = value; + int port = 25565; + if (value.startsWith("[")) { + int end = value.indexOf(']'); + if (end < 2) throw new IllegalArgumentException("Invalid IPv6 address"); + host = value.substring(1, end); + String rest = value.substring(end + 1); + if (!rest.isEmpty()) { + if (!rest.startsWith(":")) throw new IllegalArgumentException("Invalid address suffix"); + port = parsePort(rest.substring(1)); + } + if (!host.matches("[0-9a-fA-F:.%A-Za-z0-9_-]+") || !host.contains(":")) throw new IllegalArgumentException("Invalid IPv6 address"); + } else if (value.indexOf(':') != value.lastIndexOf(':')) { + throw new IllegalArgumentException("Use [IPv6]:port for IPv6 addresses"); + } else if (value.contains(":")) { + int separator = value.indexOf(':'); + host = value.substring(0, separator); port = parsePort(value.substring(separator + 1)); + } + if (host.isEmpty() || host.length() > 255 || host.startsWith("-") || !host.matches("[\\p{L}\\p{N}._:%-]+")) throw new IllegalArgumentException("Invalid server host"); + return new ServerAddress(host.toLowerCase(Locale.ROOT), port); + } + private static int parsePort(String text) { + try { int value = Integer.parseInt(text); if (value >= 1 && value <= 65535) return value; } + catch (NumberFormatException ignored) { } + throw new IllegalArgumentException("Port must be between 1 and 65535"); + } + @Override public String toString() { return (host.contains(":") ? "[" + host + "]" : host) + (port == 25565 ? "" : ":" + port); } + @Override public boolean equals(Object other) { return other instanceof ServerAddress && host.equals(((ServerAddress)other).host) && port == ((ServerAddress)other).port; } + @Override public int hashCode() { return 31 * host.hashCode() + port; } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerCatalog.java b/src/main/java/com/osiris/autoplug/client/browser/ServerCatalog.java new file mode 100644 index 00000000..b07bd17e --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerCatalog.java @@ -0,0 +1,92 @@ +package com.osiris.autoplug.client.browser; + +import com.google.gson.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.*; +import java.util.*; + +public final class ServerCatalog { + private final Path file; + public ServerCatalog(Path autoplugRoot) { file = autoplugRoot.resolve("servers.json").toAbsolutePath().normalize(); } + public synchronized List list() throws IOException { + ClientDataFiles.rejectLinks(file); + if (!Files.exists(file)) return Collections.emptyList(); + if (Files.size(file) > 1024 * 1024) throw new IOException("Saved server list is too large"); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonArray values = JsonParser.parseReader(reader).getAsJsonArray(); + if (values.size() > 1000) throw new IOException("Too many saved servers"); + List result = new ArrayList<>(); + for (JsonElement value : values) { + JsonObject entry = value.getAsJsonObject(); + result.add(new SavedServer(entry.get("name").getAsString(), ServerAddress.parse(entry.get("address").getAsString()), + entry.has("loader") ? entry.get("loader").getAsString() : "VANILLA")); + } + return Collections.unmodifiableList(result); + } catch (RuntimeException exception) { throw new IOException("Invalid saved server list", exception); } + } + public synchronized void save(List servers) throws IOException { + if (servers.size() > 1000) throw new IOException("Too many saved servers"); + JsonArray values = new JsonArray(); Set seen = new HashSet<>(); + for (SavedServer server : servers) { + if (!seen.add(server.address)) throw new IOException("Duplicate server address"); + JsonObject entry = new JsonObject(); entry.addProperty("name", server.name); entry.addProperty("address", server.address.toString()); entry.addProperty("loader", server.loaderHint); values.add(entry); + } + ClientDataFiles.atomicWrite(file, new GsonBuilder().setPrettyPrinting().create().toJson(values).getBytes(StandardCharsets.UTF_8)); + } + /** Address identifies a bookmark; stale panels cannot replace newer edits or drop imported entries. */ + public synchronized List edit(SavedServer previous, SavedServer replacement) throws IOException { + List current = new ArrayList<>(list()); + int position = previous == null ? -1 : find(current, previous.address); + if (previous != null && (position < 0 || !same(current.get(position), previous))) throw new IOException("Server changed since selection; refresh the list before editing"); + int duplicate = find(current, replacement.address); + if (duplicate >= 0 && duplicate != position) throw new IOException("This address is already saved"); + if (position >= 0) current.set(position, replacement); else current.add(replacement); + save(current); return list(); + } + public synchronized List remove(SavedServer previous) throws IOException { + List current = new ArrayList<>(list()); int position = find(current, previous.address); + if (position < 0 || !same(current.get(position), previous)) throw new IOException("Server changed since selection; refresh the list before removing"); + current.remove(position); save(current); return list(); + } + private static int find(List servers, ServerAddress address) { for (int i = 0; i < servers.size(); i++) if (servers.get(i).address.equals(address)) return i; return -1; } + private static boolean same(SavedServer first, SavedServer second) { return first.address.equals(second.address) && first.name.equals(second.name) && first.loaderHint.equals(second.loaderHint); } + public synchronized int importVanilla(Path path) throws IOException { + Map result = new LinkedHashMap<>(); + for (SavedServer server : list()) result.put(server.address, server); + int initial = result.size(); + for (SavedServer server : MinecraftServersDat.read(path).servers()) result.putIfAbsent(server.address, server); + save(new ArrayList<>(result.values())); return result.size() - initial; + } + /** User-requested export replaces the vanilla list, preserves retained icons/tags and creates a unique backup. */ + public synchronized Path exportVanilla(Path path) throws IOException { + path = path.toAbsolutePath().normalize(); ClientDataFiles.rejectLinks(path); + boolean exists = Files.exists(path); + if (exists && (!Files.isRegularFile(path) || Files.size(path) > 32 * 1024 * 1024)) throw new IOException("Invalid or oversized servers.dat"); + byte[] before = exists ? digest(path) : null; + MinecraftServersDat.Document document = exists ? MinecraftServersDat.read(path) : MinecraftServersDat.empty(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + MinecraftServersDat.write(document.withServers(list()), output); + if (exists != Files.exists(path) || (exists && !MessageDigest.isEqual(before, digest(path)))) throw new IOException("servers.dat changed; export cancelled"); + Path backup = null; + if (exists) { + backup = path.resolveSibling(path.getFileName() + ".autoplug-backup-" + UUID.randomUUID()); + Files.copy(path, backup); + } + ClientDataFiles.atomicWrite(path, output.toByteArray()); return backup; + } + private static byte[] digest(Path path) throws IOException { + try { + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(path)) { byte[] buffer = new byte[8192]; int size; while ((size = input.read(buffer)) != -1) hash.update(buffer, 0, size); } + return hash.digest(); + } catch (NoSuchAlgorithmException exception) { throw new AssertionError(exception); } + } + public static Path vanillaPath() { + String appData = System.getenv("APPDATA"); + if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win") && appData != null) return Paths.get(appData, ".minecraft", "servers.dat"); + if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("mac")) return Paths.get(System.getProperty("user.home"), "Library", "Application Support", "minecraft", "servers.dat"); + return Paths.get(System.getProperty("user.home"), ".minecraft", "servers.dat"); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java b/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java new file mode 100644 index 00000000..f0520787 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java @@ -0,0 +1,31 @@ +package com.osiris.autoplug.client.browser; + +import com.osiris.autoplug.client.utils.MineStat; +import java.util.regex.*; + +/** Server browser adapter for AutoPlug's existing MineStat utility. */ +public final class ServerStatus { + public final SavedServer server; + public final boolean online; + public final String motd, version, failure; + public final int protocol, players, capacity; + public final long latency; + public ServerStatus(SavedServer server, boolean online, String motd, String version, int protocol, int players, int capacity, long latency, String failure) { + this.server = server; this.online = online; this.motd = motd; this.version = version; + this.protocol = protocol; this.players = players; this.capacity = capacity; this.latency = latency; this.failure = failure; + } + public static ServerStatus ping(SavedServer server) { + MineStat ping = new MineStat(server.address.host, server.address.port, 3, MineStat.Request.JSON); + return new ServerStatus(server, ping.isServerUp(), ping.getStrippedMotd(), ping.getVersion(), ping.getProtocol(), + ping.getCurrentPlayers(), ping.getMaximumPlayers(), ping.getLatency(), ping.pingResult.name()); + } + /** Vanilla identifiers embedded in Paper/Forge labels; ambiguous proxy ranges require a user choice. */ + public String gameVersion() { + if (!online || version == null) throw new IllegalStateException("Server did not return a version"); + Matcher match = Pattern.compile("(? javaPaths = new LinkedHashMap<>(); + private final Path file; + public ClientSettings(Path root) throws IOException { + file = root.resolve("client-settings.properties"); + ClientDataFiles.rejectLinks(file); + if (!Files.exists(file)) return; + if (Files.size(file) > 32 * 1024) throw new IOException("Client settings are too large"); + Properties values = new Properties(); try (InputStream input = Files.newInputStream(file)) { values.load(input); } + offlineName = values.getProperty("offlineName", "Player"); templateId = values.getProperty("templateId", ""); + microsoftClientId = values.getProperty("microsoftClientId", ""); offlineMode = Boolean.parseBoolean(values.getProperty("offlineMode", "true")); + try { worldPort = Integer.parseInt(values.getProperty("worldPort", "0")); validatePort(worldPort); } + catch (IllegalArgumentException invalid) { throw new IOException("Invalid saved virtual world port", invalid); } + upnpAllowed = Boolean.parseBoolean(values.getProperty("upnpAllowed", "true")); + for (int major : new int[]{8, 17, 21, 25}) javaPaths.put(major, values.getProperty("java." + major, "")); + } + public synchronized void save() throws IOException { + apply(offlineName, offlineMode, microsoftClientId, templateId, new LinkedHashMap<>(javaPaths)); + } + /** Validate and persist a candidate before making it visible to a running launch worker. */ + public synchronized void apply(String offlineName, boolean offlineMode, String microsoftClientId, String templateId, Map javaPaths) throws IOException { + apply(offlineName, offlineMode, microsoftClientId, templateId, javaPaths, worldPort, upnpAllowed); + } + public synchronized void apply(String offlineName, boolean offlineMode, String microsoftClientId, String templateId, Map javaPaths, int worldPort, boolean upnpAllowed) throws IOException { + javaPaths = new LinkedHashMap<>(javaPaths); + validatePort(worldPort); + if (offlineName == null || !offlineName.matches("[a-zA-Z0-9_]{1,16}")) throw new IllegalArgumentException("Offline name must contain 1–16 letters, digits or underscores"); + if (microsoftClientId == null || (!microsoftClientId.isEmpty() && !microsoftClientId.matches("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"))) throw new IllegalArgumentException("Enter your registered public Microsoft application's client ID"); + if (templateId == null || (!templateId.isEmpty() && !templateId.matches("[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}"))) throw new IllegalArgumentException("Invalid template profile ID"); + Properties values = new Properties(); values.setProperty("offlineName", offlineName); values.setProperty("offlineMode", String.valueOf(offlineMode)); + values.setProperty("microsoftClientId", microsoftClientId); values.setProperty("templateId", templateId); + values.setProperty("worldPort", Integer.toString(worldPort)); values.setProperty("upnpAllowed", Boolean.toString(upnpAllowed)); + for (int major : new int[]{8, 17, 21, 25}) values.setProperty("java." + major, javaPaths.getOrDefault(major, "")); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); values.store(bytes, "AutoPlug client preferences; no account tokens"); + ClientDataFiles.atomicWrite(file, bytes.toByteArray()); + this.offlineName = offlineName; this.offlineMode = offlineMode; this.microsoftClientId = microsoftClientId; this.templateId = templateId; + this.javaPaths.clear(); this.javaPaths.putAll(javaPaths); + this.worldPort = worldPort; this.upnpAllowed = upnpAllowed; + } + private static void validatePort(int port) { if (port != 0 && (port < 1024 || port > 65535)) throw new IllegalArgumentException("World port must be 0 (automatic) or 1024–65535"); } + public int worldPort() { return worldPort; } + public boolean upnpAllowed() { return upnpAllowed; } + public synchronized Path javaOverride(int major) { String path = javaPaths.get(major); return path == null || path.trim().isEmpty() ? null : Paths.get(path); } +} diff --git a/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientCommands.java b/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientCommands.java new file mode 100644 index 00000000..9366f2e9 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientCommands.java @@ -0,0 +1,55 @@ +package com.osiris.autoplug.client.clientapp; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.launcher.*; +import java.util.*; +import java.util.function.Consumer; + +/** Headless client commands share the same profile store, preferences and launch service as Swing. */ +public final class NativeClientCommands { + private final NativeClientService service; + public NativeClientCommands(NativeClientService service) { this.service = service; } + public static boolean tryHandle(String command, Consumer output) { + if (command == null || !(command.trim().equals(".client") || command.trim().startsWith(".client "))) return false; + try { return new NativeClientCommands(NativeClientService.getDefault()).handle(command, output); } + catch (Exception exception) { output.accept("Client command failed: " + exception.getMessage()); return true; } + } + public boolean handle(String command, Consumer output) { + if (command == null || !(command.trim().equals(".client") || command.trim().startsWith(".client "))) return false; + try { + String[] words = command.trim().split("\\s+"); + if (words.length == 1 || words[1].equals("help")) { + output.accept(".client launch [--server ]\n.client servers list|import|export\n.client auth offline \n.client auth microsoft \n.client auth signout"); + } else if (words[1].equals("launch")) { + if (words.length != 3 && !(words.length == 5 && words[3].equals("--server"))) throw new IllegalArgumentException("Usage: .client launch [--server ]"); + service.launch(words[2], words.length == 5 ? ServerAddress.parse(words[4]) : null); + output.accept("Minecraft process started for " + words[2]); + } else if (words[1].equals("servers") && words.length == 3) { + switch (words[2]) { + case "list": for (SavedServer server : service.servers.list()) { + ServerStatus status = ServerStatus.ping(server); + output.accept(server + " | " + (status.online ? status.version + " protocol=" + status.protocol + " | " + status.players + "/" + status.capacity + " | " + status.latency + "ms | " + status.motd : status.failure)); + } break; + case "import": output.accept("Imported " + service.servers.importVanilla(ServerCatalog.vanillaPath()) + " servers"); break; + case "export": output.accept("Exported servers.dat; backup: " + service.servers.exportVanilla(ServerCatalog.vanillaPath())); break; + default: throw new IllegalArgumentException("Usage: .client servers list|import|export"); + } + } else if (words[1].equals("auth")) { + if (words.length == 3 && words[2].equals("signout")) { service.signOut(); output.accept("Signed out"); } + else if (words.length == 4 && words[2].equals("offline")) { + service.useOfflineAccount(words[3]); + output.accept("Offline testing enabled; online authenticated servers require a licensed Microsoft account"); + } else if (words.length == 4 && words[2].equals("microsoft")) { + MicrosoftOAuth oauth = new MicrosoftOAuth(words[3]); + long attempt = service.beginAuthentication(); + MicrosoftOAuth.DeviceAuthorization device = oauth.beginDeviceAuthorization(); + output.accept("Open " + device.getVerificationUri() + " and enter " + device.getUserCode() + ". Waiting for your sign-in until " + device.getExpiresAt()); + LaunchAccount account = oauth.awaitMinecraftAccount(device, () -> Thread.currentThread().isInterrupted() || !service.authenticationCurrent(attempt)); + if (!service.completeAuthenticationAndSave(attempt, account, oauth, words[3])) throw new IllegalStateException("Sign-in cancelled"); + output.accept("Signed in as " + account.getName() + "; session is kept only in memory"); + } else throw new IllegalArgumentException("Usage: .client auth offline |microsoft |signout"); + } else throw new IllegalArgumentException("Unknown client command. Enter .client help"); + } catch (Exception exception) { output.accept("Client command failed: " + exception.getMessage()); } + return true; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientService.java b/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientService.java new file mode 100644 index 00000000..afc2d867 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/clientapp/NativeClientService.java @@ -0,0 +1,154 @@ +package com.osiris.autoplug.client.clientapp; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.profiles.*; +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.time.Instant; + +/** Shared UI/CLI orchestration. A launch never mutates an existing server installation. */ +public final class NativeClientService { + public interface Confirmation { boolean confirm(String message); } + public interface LaunchExecutor { Process launch(Profile profile, LaunchAccount account, ServerAddress address) throws Exception; } + public interface Migration { + ProfileUpdater.Plan check(Profile profile) throws Exception; + void apply(ProfileUpdater.Plan plan, boolean allowDisabling) throws Exception; + } + public final ProfileStore profiles; + public final ServerCatalog servers; + public final ClientSettings settings; + private final LaunchExecutor launcher; + private final Migration migration; + private final AtomicBoolean working = new AtomicBoolean(); + private volatile LaunchAccount authenticatedAccount; + private volatile MicrosoftOAuth oauthSession; + private volatile long authenticationGeneration; + private static volatile NativeClientService instance; + public NativeClientService(ProfileStore profiles, ServerCatalog servers, ClientSettings settings, LaunchExecutor launcher, Migration migration) { + this.profiles = profiles; this.servers = servers; this.settings = settings; this.launcher = launcher; this.migration = migration; + } + public static synchronized NativeClientService getDefault() throws IOException { + if (instance == null) { + ProfileStore store = ProfileStore.defaultStore(); ClientSettings settings = new ClientSettings(store.getRoot()); + Path minecraftCache = store.getRoot().resolve("cache").resolve("minecraft"); + ManagedJavaRuntime runtimes = new ManagedJavaRuntime(minecraftCache.resolve("runtime")); + NativeLauncher nativeLauncher = new NativeLauncher(minecraftCache, new LauncherDownloads.HttpsTransport(), LauncherPlatform.current(), major -> { + Path override = settings.javaOverride(major); + if (override != null) { ManagedJavaRuntime.verifyMajor(override, major); return override; } + return runtimes.resolve(major); + }); + ProfileUpdater updater = new ProfileUpdater(store.getCache()); + instance = new NativeClientService(store, new ServerCatalog(store.getRoot()), settings, (profile, account, address) -> { + LauncherDownloads.checkInterrupted(); + LaunchPlan plan = nativeLauncher.prepare(profile.gameVersion, profile.loader, profile.directoryPath, account); + Path override = settings.javaOverride(plan.getJavaMajor()); + if (override != null) ManagedJavaRuntime.verifyMajor(override, plan.getJavaMajor()); + Path java = override != null ? override : nativeLauncher.resolveJava(plan); + LauncherDownloads.checkInterrupted(); + return plan.launch(java, address == null ? null : address.host, address == null ? 25565 : address.port); + }, new Migration() { + public ProfileUpdater.Plan check(Profile profile) throws Exception { return updater.check(profile); } + public void apply(ProfileUpdater.Plan plan, boolean allowDisabling) throws Exception { updater.apply(plan, allowDisabling); } + }); + } + return instance; + } + public synchronized void setAuthenticatedAccount(LaunchAccount account) { setAuthenticatedAccount(account, null); } + public synchronized void setAuthenticatedAccount(LaunchAccount account, MicrosoftOAuth oauth) { + Objects.requireNonNull(account); + authenticationGeneration++; + if (oauthSession != null && oauthSession != oauth) oauthSession.clearSession(); + oauthSession = oauth; authenticatedAccount = account; + } + public synchronized void signOut() { authenticationGeneration++; MicrosoftOAuth oauth = oauthSession; if (oauth != null) oauth.clearSession(); oauthSession = null; authenticatedAccount = null; } + public synchronized long beginAuthentication() { return ++authenticationGeneration; } + public boolean authenticationCurrent(long attempt) { return attempt == authenticationGeneration; } + public synchronized void cancelAuthentication(long attempt) { if (authenticationCurrent(attempt)) authenticationGeneration++; } + public synchronized boolean completeAuthentication(long attempt, LaunchAccount account, MicrosoftOAuth oauth) { + if (!authenticationCurrent(attempt)) { if (oauth != null) oauth.clearSession(); return false; } + setAuthenticatedAccount(account, oauth); return true; + } + /** Preference changes cancel pending authentication without discarding an unchanged online session. */ + public synchronized void applyPreferences(String offlineName, boolean offlineMode, String clientId, String templateId, Map paths) throws IOException { + applyPreferences(offlineName, offlineMode, clientId, templateId, paths, settings.worldPort(), settings.upnpAllowed()); + } + public synchronized void applyPreferences(String offlineName, boolean offlineMode, String clientId, String templateId, Map paths, int worldPort, boolean upnpAllowed) throws IOException { + String previousClient = settings.microsoftClientId; + settings.apply(offlineName, offlineMode, clientId, templateId, paths, worldPort, upnpAllowed); + if (offlineMode || !Objects.equals(previousClient, clientId)) signOut(); + else authenticationGeneration++; + } + public synchronized void useOfflineAccount(String name) throws IOException { + applyPreferences(name, true, settings.microsoftClientId, settings.templateId, settings.javaPaths); + } + /** Check cancellation, persist the mode and install the account as one service transaction. */ + public synchronized boolean completeAuthenticationAndSave(long attempt, LaunchAccount account, MicrosoftOAuth oauth, String clientId) throws IOException { + if (!authenticationCurrent(attempt)) { if (oauth != null) oauth.clearSession(); return false; } + Objects.requireNonNull(account); + try { settings.apply(settings.offlineName, false, clientId, settings.templateId, settings.javaPaths); } + catch (IOException | RuntimeException failure) { if (oauth != null) oauth.clearSession(); cancelAuthentication(attempt); throw failure; } + setAuthenticatedAccount(account, oauth); return true; + } + public boolean isSignedIn() { return authenticatedAccount != null; } + public String accountLabel() { LaunchAccount account = authenticatedAccount; return account == null ? "Not signed in" : account.getName(); } + private LaunchAccount account() throws IOException { + if (settings.offlineMode) return LaunchAccount.offline(settings.offlineName); + LaunchAccount account; + MicrosoftOAuth session; + synchronized (this) { account = authenticatedAccount; session = oauthSession; } + if (account == null) throw new IllegalStateException("Sign in with Microsoft in Settings first, or explicitly enable offline testing"); + if (session != null && session.getAccessTokenExpiresAt() != null && Instant.now().plusSeconds(60).isAfter(session.getAccessTokenExpiresAt())) { + String refresh = session.getRefreshToken(); + if (refresh == null) { signOut(); throw new IOException("Account session expired. Sign in again."); } + LaunchAccount refreshed = session.refresh(refresh); + synchronized (this) { + if (session != oauthSession || account != authenticatedAccount) { session.clearSession(); throw new IOException("Account session changed while refreshing; launch cancelled"); } + account = refreshed; authenticatedAccount = account; + } + } + return account; + } + public Process launch(String id, ServerAddress address) throws Exception { + if (!working.compareAndSet(false, true)) throw new IllegalStateException("A client preparation is already running"); + try { Profile profile = profiles.get(id); requireClient(profile); return launcher.launch(profile, account(), address); } + finally { working.set(false); } + } + public Process autoPlay(ServerStatus status, Confirmation confirmation) throws Exception { + if (!working.compareAndSet(false, true)) throw new IllegalStateException("A client preparation is already running"); + Profile cloned = null; + try { + LaunchAccount account = account(); + String version = status.gameVersion(), loader = status.server.loaderHint; + for (Profile profile : profiles.list()) { + if (profile.type == Profile.Type.MODS && profile.gameVersion.equals(version) && profile.loader.equals(loader) && !profile.isTemplate) + return launcher.launch(profile, account, status.server.address); + } + Profile template = selectTemplate(loader); + if (!confirmation.confirm("No " + loader + " profile for " + version + ". Clone template '" + template.id + "', migrate its mods and join " + status.server.address + "?")) return null; + cloned = profiles.clone(template.id, version); + ProfileUpdater.Plan plan = migration.check(cloned); + if (!plan.errors.isEmpty()) throw new IOException(String.join("\n", plan.errors)); + boolean disabling = !plan.disabled().isEmpty(); + if (plan.hasChanges() && !confirmation.confirm(plan.summary() + "\nApply these changes to the new profile?")) { profiles.delete(cloned.id); cloned = null; return null; } + migration.apply(plan, disabling); + Process process = launcher.launch(cloned, account, status.server.address); + cloned = null; return process; + } catch (Exception failure) { + if (cloned != null) try { profiles.delete(cloned.id); } catch (IOException cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } finally { working.set(false); } + } + private Profile selectTemplate(String loader) throws IOException { + if (!settings.templateId.isEmpty()) { + Profile selected = profiles.get(settings.templateId); + if (selected.type != Profile.Type.MODS || !selected.isTemplate || !selected.loader.equals(loader)) throw new IOException("Default template must be a client template using " + loader); + return selected; + } + for (Profile profile : profiles.list()) if (profile.type == Profile.Type.MODS && profile.isTemplate && profile.loader.equals(loader)) return profile; + throw new IOException("Create a " + loader + " client template in Profiles and choose it in Settings"); + } + private static void requireClient(Profile profile) { if (profile.type != Profile.Type.MODS) throw new IllegalArgumentException("Only MODS client profiles can launch a Minecraft client; server profiles require a virtual world"); } +} diff --git a/src/main/java/com/osiris/autoplug/client/clientapp/StartupCommands.java b/src/main/java/com/osiris/autoplug/client/clientapp/StartupCommands.java new file mode 100644 index 00000000..3589a66b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/clientapp/StartupCommands.java @@ -0,0 +1,31 @@ +package com.osiris.autoplug.client.clientapp; + +import java.util.*; +import java.util.function.Consumer; +import com.osiris.autoplug.client.profiles.ProfileCommands; + +/** Startup commands are separated by a dot at a token boundary, not dots inside versions or hosts. */ +public final class StartupCommands { + private StartupCommands() { } + public static List parse(List arguments) { + String input = String.join(" ", arguments).trim(); + List commands = new ArrayList<>(); + for (String candidate : input.split("(?=\\s+\\.[A-Za-z])")) { + String text = candidate.trim(); if (text.startsWith(".")) commands.add(text); + } + return Collections.unmodifiableList(commands); + } + public static boolean clientOnly(List commands) { + if (commands.isEmpty()) return false; + for (String command : commands) if (!(command.equals(".mc") || command.startsWith(".mc ") || command.equals(".client") || command.startsWith(".client ") || command.equals(".profiles") || command.startsWith(".profiles ") || command.matches("\\.(check|update) (mods|plugins|mods-server|mods_server)(?: .*|$)"))) return false; + return true; + } + /** One-shot client/profile work skips server startup, background recurring tasks and online console. */ + public static boolean executeClientOnly(List arguments, Consumer output) { + List commands = parse(arguments); if (!clientOnly(commands)) return false; + for (String command : commands) { + if (!ProfileCommands.tryHandle(command, output) && !WorldCommands.tryHandle(command, output, true) && !NativeClientCommands.tryHandle(command, output)) output.accept("No active isolated profile for: " + command + ". Use .profiles activate , or run the legacy server console for current-directory checks."); + } + return true; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/clientapp/WorldClientContext.java b/src/main/java/com/osiris/autoplug/client/clientapp/WorldClientContext.java new file mode 100644 index 00000000..abb2d4b9 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/clientapp/WorldClientContext.java @@ -0,0 +1,22 @@ +package com.osiris.autoplug.client.clientapp; + +import com.osiris.autoplug.client.worlds.*; +import com.osiris.autoplug.client.worlds.network.UpnpPortMapper; +import java.io.IOException; +import java.util.*; + +/** A single shared world store/lifecycle owner for the service's GUI and console. */ +public final class WorldClientContext { + private static final Map instances = new IdentityHashMap<>(); + public final WorldStore worlds; + public final VirtualWorldService lifecycle; + private WorldClientContext(NativeClientService service) throws IOException { + worlds = new WorldStore(service.profiles.getRoot(), service.profiles); + lifecycle = new VirtualWorldService(worlds, service, new UpnpPortMapper()); + } + public static synchronized WorldClientContext of(NativeClientService service) throws IOException { + WorldClientContext context = instances.get(service); + if (context == null) { context = new WorldClientContext(service); instances.put(service, context); } + return context; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/clientapp/WorldCommands.java b/src/main/java/com/osiris/autoplug/client/clientapp/WorldCommands.java new file mode 100644 index 00000000..65962655 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/clientapp/WorldCommands.java @@ -0,0 +1,50 @@ +package com.osiris.autoplug.client.clientapp; + +import com.osiris.autoplug.client.worlds.*; +import java.nio.file.Paths; +import java.util.*; +import java.util.function.Consumer; + +/** .mc is the combined client/world interface; .client remains a supported alias. */ +public final class WorldCommands { + private final WorldStore worlds; + private final VirtualWorldService lifecycle; + public WorldCommands(WorldStore worlds, VirtualWorldService lifecycle) { this.worlds = worlds; this.lifecycle = lifecycle; } + public static boolean tryHandle(String command, Consumer output, boolean waitForClient) { + if (command == null || !(command.trim().equals(".mc") || command.trim().startsWith(".mc "))) return false; + String text = command.trim(); + if (!(text.equals(".mc") || text.equals(".mc help") || text.startsWith(".mc worlds"))) return NativeClientCommands.tryHandle(".client" + text.substring(3), output); + try { WorldClientContext context = WorldClientContext.of(NativeClientService.getDefault()); return new WorldCommands(context.worlds, context.lifecycle).handle(text, output, waitForClient); } + catch (Exception failure) { output.accept("World command failed: " + failure.getMessage()); return true; } + } + public boolean handle(String command, Consumer output, boolean waitForClient) { + if (command == null || !(command.trim().equals(".mc") || command.trim().startsWith(".mc worlds") || command.trim().equals(".mc help"))) return false; + try { + String[] words = command.trim().split("\\s+"); + if (words.length <= 2 || words[1].equals("help")) { usage(output); return true; } + String action = words[2]; + if (action.equals("list") && words.length == 3) for (WorldDefinition world : worlds.list()) output.accept(world + (lifecycle.isRunning(world.id) ? " | running" : " | stopped") + (world.acceptedEula ? "" : " | EULA acceptance needed")); + else if (action.equals("create") && words.length >= 5) output.accept("Created world " + worlds.create(String.join(" ", Arrays.copyOfRange(words, 3, words.length - 1)), words[words.length - 1])); + else if (action.equals("client") && words.length == 5) output.accept("World client: " + worlds.setClientProfile(words[3], words[4].equals("default") ? null : words[4])); + else if (action.equals("jar") && words.length >= 5) output.accept("World server executable: " + worlds.setServerExecutable(words[3], Paths.get(String.join(" ", Arrays.copyOfRange(words, 4, words.length))))); + else if (action.equals("accept-eula") && words.length == 4) output.accept("Minecraft EULA acceptance saved for " + worlds.acceptEula(words[3], true).id); + else if (action.equals("revoke-eula") && words.length == 4) output.accept("Minecraft EULA acceptance removed for " + worlds.acceptEula(words[3], false).id); + else if (action.equals("delete") && words.length == 5 && words[4].equals("--confirm")) { if (lifecycle.isRunning(words[3])) throw new IllegalStateException("Stop the world before deleting its save data"); worlds.delete(words[3]); output.accept("Deleted world " + words[3]); } + else if (action.equals("stop") && words.length == 4) { VirtualWorldService.RunningWorld running = lifecycle.running(words[3]); lifecycle.stop(words[3]); output.accept("Stopped world " + words[3]); if (running != null && running.cleanupFailure() != null) output.accept(running.cleanupFailure()); } + else if (action.equals("share") && words.length == 4) output.accept("Join address: " + lifecycle.share(words[3])); + else if (action.equals("launch") && (words.length == 4 || words.length == 5 && words[4].equals("--share"))) { + VirtualWorldService.RunningWorld running = lifecycle.launch(words[3], words.length == 5); + try { + output.accept("World client started: " + running.address); + String address = running.publicAddress(); if (address != null) output.accept("Join address: " + address); + if (running.shareFailure() != null) output.accept("World is running locally; sharing failed: " + running.shareFailure()); + if (waitForClient) running.clientProcess().waitFor(); + } finally { if (waitForClient) { running.close(); if (running.cleanupFailure() != null) output.accept(running.cleanupFailure()); } } + } else throw new IllegalArgumentException("Enter .mc worlds for usage; deleting a world requires --confirm"); + } catch (Exception failure) { if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); output.accept("World command failed: " + failure.getMessage()); } + return true; + } + private static void usage(Consumer output) { + output.accept(".mc worlds list\n.mc worlds create \n.mc worlds client \n.mc worlds jar \n.mc worlds accept-eula (review https://www.minecraft.net/en-us/eula first)\n.mc worlds revoke-eula \n.mc worlds launch [--share]\n.mc worlds share \n.mc worlds stop \n.mc worlds delete --confirm\n.mc launch [--server ]\n.mc servers list|import|export\nSharing is an explicit request for a temporary TCP router mapping and requires authenticated online play. A loopback-only world must be restarted with --share. Client/profile-only startup waits for a world's client to exit and then cleans up its server/mapping."); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java b/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java index b8f3d60d..ce35fe96 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java @@ -67,7 +67,7 @@ public GeneralConfig(boolean save) throws IOException, DuplicateKeyException, Ya try { if (this.autoplug_system_tray.asBoolean()) { boolean firstStart = MainWindow.GET == null; - new MainWindow(this); + MainWindow.open(this); if (firstStart) AL.info("Started system-tray GUI."); } else { boolean isRunning = MainWindow.GET != null; diff --git a/src/main/java/com/osiris/autoplug/client/console/Commands.java b/src/main/java/com/osiris/autoplug/client/console/Commands.java index 5e37ec10..33255d7f 100644 --- a/src/main/java/com/osiris/autoplug/client/console/Commands.java +++ b/src/main/java/com/osiris/autoplug/client/console/Commands.java @@ -93,6 +93,9 @@ public static boolean execute(@NotNull String command) { if (first.equals(".")) { try { + if (com.osiris.autoplug.client.profiles.ProfileCommands.tryHandle(command, AL::info)) return true; + if (com.osiris.autoplug.client.clientapp.WorldCommands.tryHandle(command, AL::info, false)) return true; + if (com.osiris.autoplug.client.clientapp.NativeClientCommands.tryHandle(command, AL::info)) return true; SSHConfig sshConfig = new SSHConfig(); if (command.equals(".help") || command.equals(".h")) { AL.info(""); @@ -129,6 +132,10 @@ public static boolean execute(@NotNull String command) { AL.info(".check server | Checks for server updates (.cs)"); AL.info(".check plugins | Checks for plugins updates (.cp)"); AL.info(".check mods | Checks for mods updates (.cm)"); + AL.info(".profiles help | Unified MODS / PLUGINS / MODS_SERVER profiles"); + AL.info(".check/.update mods|plugins --profile [--confirm-disabling] | Profile-scoped updates"); + AL.info(".client help | Native client launch, saved servers and account settings"); + AL.info(".mc help | Native client and virtual worlds, local server lifecycle and opt-in sharing"); AL.info(""); AL.info(".ssh stop | Stops the SSH-Server"); AL.info(".ssh start | Starts the SSH-Server"); diff --git a/src/main/java/com/osiris/autoplug/client/launcher/JavaResolver.java b/src/main/java/com/osiris/autoplug/client/launcher/JavaResolver.java new file mode 100644 index 00000000..e3540cf9 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/JavaResolver.java @@ -0,0 +1,4 @@ +package com.osiris.autoplug.client.launcher; +import java.nio.file.Path; +@FunctionalInterface +public interface JavaResolver { Path resolve(int majorVersion) throws Exception; } diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LaunchAccount.java b/src/main/java/com/osiris/autoplug/client/launcher/LaunchAccount.java new file mode 100644 index 00000000..566e6fb4 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LaunchAccount.java @@ -0,0 +1,40 @@ +package com.osiris.autoplug.client.launcher; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** Credentials exist only in memory. Do not log a launch command containing the access token. */ +public final class LaunchAccount { + private final String name, uuid, accessToken, userType, xuid, clientId; + + private LaunchAccount(String name, String uuid, String token, String type, String xuid, String clientId) { + if (name == null || !name.matches("[A-Za-z0-9_]{1,16}")) + throw new IllegalArgumentException("Minecraft name must contain 1–16 letters, digits or underscores"); + this.name = name; + this.uuid = uuid.replace("-", ""); + if (!this.uuid.matches("[a-fA-F0-9]{32}")) throw new IllegalArgumentException("Invalid Minecraft UUID"); + this.accessToken = token; + this.userType = type; + this.xuid = xuid == null ? "" : xuid; + this.clientId = clientId == null ? "" : clientId; + } + + public static LaunchAccount offline(String name) { + UUID uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)); + return new LaunchAccount(name, uuid.toString(), "0", "legacy", "", ""); + } + + public static LaunchAccount microsoft(String name, String uuid, String accessToken, String xuid, String clientId) { + if (accessToken == null || accessToken.isEmpty()) throw new IllegalArgumentException("Missing Minecraft access token"); + return new LaunchAccount(name, uuid, accessToken, "msa", xuid, clientId); + } + + public String getName() { return name; } + public String getUuid() { return uuid; } + public String getAccessToken() { return accessToken; } + public String getUserType() { return userType; } + public String getXuid() { return xuid; } + public String getClientId() { return clientId; } + public boolean isOffline() { return "legacy".equals(userType); } + @Override public String toString() { return "LaunchAccount{" + name + ", " + userType + "}"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LaunchPlan.java b/src/main/java/com/osiris/autoplug/client/launcher/LaunchPlan.java new file mode 100644 index 00000000..3a54a486 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LaunchPlan.java @@ -0,0 +1,132 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import java.io.File; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; + +/** A prepared installation. Command arguments are a list, never a shell command. */ +public final class LaunchPlan { + private final JsonObject metadata; + private final Path directory, natives, assets, libraries, client; + private final List classpath; + private final LaunchAccount account; + private final LauncherPlatform platform; + private final String gameVersion; + private final Path loggingConfig; + LaunchPlan(JsonObject metadata, String gameVersion, Path directory, Path natives, Path assets, + Path libraries, Path client, List classpath, LaunchAccount account, + LauncherPlatform platform, Path loggingConfig) { + this.metadata = metadata.deepCopy(); this.gameVersion = gameVersion; this.directory = directory; + this.natives = natives; this.assets = assets; this.libraries = libraries; this.client = client; + this.classpath = Collections.unmodifiableList(new ArrayList<>(classpath)); + this.account = Objects.requireNonNull(account); this.platform = platform; this.loggingConfig = loggingConfig; + } + public int getJavaMajor() { + return metadata.has("javaVersion") ? metadata.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + } + public Path getGameDirectory() { return directory; } + public String getVersion() { return metadata.get("id").getAsString(); } + public String getGameVersion() { return gameVersion; } + public List getClasspath() { return classpath; } + public List buildCommand(Path javaExecutable, String host, int port) { + if (host != null && (host.isEmpty() || host.indexOf('\0') >= 0 || host.indexOf('\n') >= 0 || host.startsWith("-"))) + throw new IllegalArgumentException("Invalid server host"); + if (host != null && (port < 1 || port > 65535)) throw new IllegalArgumentException("Invalid server port"); + Map values = new HashMap<>(); + String cp = String.join(File.pathSeparator, pathStrings(classpath)); + values.put("auth_player_name", account.getName()); values.put("auth_uuid", account.getUuid()); + values.put("auth_access_token", account.getAccessToken()); values.put("auth_xuid", account.getXuid()); + values.put("clientid", account.getClientId()); values.put("user_type", account.getUserType()); + values.put("auth_session", "token:" + account.getAccessToken() + ":" + account.getUuid()); + values.put("user_properties", "{}"); values.put("profile_name", account.getName()); + values.put("version_name", getVersion()); values.put("version_type", NativeLauncher.string(metadata, "type", "release")); + values.put("game_directory", directory.toString()); values.put("assets_root", assets.toString()); + String assetId = metadata.has("assetIndex") ? metadata.getAsJsonObject("assetIndex").get("id").getAsString() + : NativeLauncher.string(metadata, "assets", "legacy"); + values.put("assets_index_name", assetId); + values.put("game_assets", assets.resolve("virtual").resolve(assetId).toString()); + values.put("natives_directory", natives.toString()); values.put("library_directory", libraries.toString()); + values.put("classpath_separator", File.pathSeparator); values.put("classpath", cp); + values.put("launcher_name", "AutoPlug"); values.put("launcher_version", "10.2.0"); + values.put("resolution_width", "854"); values.put("resolution_height", "480"); + values.put("quickPlayPath", directory.resolve("autoplug-quick-play.json").toString()); + List command = new ArrayList<>(); command.add(javaExecutable.toAbsolutePath().toString()); + command.add("-Xmx2G"); + JsonObject arguments = metadata.has("arguments") ? metadata.getAsJsonObject("arguments") : null; + List jvm = arguments != null && arguments.has("jvm") + ? readArguments(arguments.getAsJsonArray("jvm"), platform, Collections.emptyMap()) : new ArrayList<>(); + if (jvm.isEmpty()) { jvm.add("-Djava.library.path=${natives_directory}"); jvm.add("-cp"); jvm.add("${classpath}"); } + for (String argument : jvm) command.add(substitute(argument, values)); + if (loggingConfig != null && metadata.has("logging")) { + String argument = metadata.getAsJsonObject("logging").getAsJsonObject("client").get("argument").getAsString(); + command.add(substitute(argument.replace("${path}", loggingConfig.toString()), values)); + } + command.add(metadata.get("mainClass").getAsString()); + List game = arguments != null && arguments.has("game") + ? readArguments(arguments.getAsJsonArray("game"), platform, Collections.emptyMap()) + : splitLegacy(NativeLauncher.string(metadata, "minecraftArguments", "")); + for (String argument : game) command.add(substitute(argument, values)); + if (host != null) { + // Mojang removed --server/--port in 23w14a (released in 1.20); use its replacement. + boolean metadataQuickPlay = arguments != null && arguments.has("game") + && arguments.getAsJsonArray("game").toString().contains("--quickPlayMultiplayer"); + if (metadataQuickPlay || supportsQuickPlayOnly(gameVersion)) { + command.add("--quickPlayMultiplayer"); + command.add((host.contains(":") && !host.startsWith("[") ? "[" + host + "]" : host) + ":" + port); + } else { command.add("--server"); command.add(host); command.add("--port"); command.add(Integer.toString(port)); } + } + return Collections.unmodifiableList(command); + } + public Process launch(Path javaExecutable, String host, int port) throws IOException { + LauncherDownloads.checkInterrupted(); + LauncherDownloads.rejectLinks(directory); + if (!Files.isRegularFile(javaExecutable)) throw new IOException("Selected Java executable does not exist"); + ProcessBuilder builder = new ProcessBuilder(buildCommand(javaExecutable, host, port)); + builder.directory(directory.toFile()); builder.redirectErrorStream(true); + // Never persist the command or tokens. Inherit output rather than leaving an unread pipe that can deadlock. + builder.inheritIO(); return builder.start(); + } + private static List pathStrings(List paths) { List list = new ArrayList<>(); for (Path p : paths) list.add(p.toString()); return list; } + static boolean supportsQuickPlayOnly(String version) { + String[] pieces = version.split("\\."); + try { return pieces.length >= 2 && (Integer.parseInt(pieces[0]) > 1 || Integer.parseInt(pieces[1]) >= 20); } + catch (NumberFormatException e) { return false; } + } + static List readArguments(JsonArray array, LauncherPlatform platform, Map features) { + List result = new ArrayList<>(); + for (JsonElement element : array) { + if (element.isJsonPrimitive()) result.add(element.getAsString()); + else { + JsonObject conditional = element.getAsJsonObject(); if (!platform.allows(conditional, features)) continue; + JsonElement value = conditional.get("value"); + if (value.isJsonArray()) for (JsonElement item : value.getAsJsonArray()) result.add(item.getAsString()); + else result.add(value.getAsString()); + } + } + return result; + } + static String substitute(String text, Map values) { + Matcher matcher = Pattern.compile("\\$\\{([^}]+)}").matcher(text); StringBuffer result = new StringBuffer(); + while (matcher.find()) { + String value = values.get(matcher.group(1)); + if (value == null) throw new IllegalArgumentException("Unsupported launcher variable: " + matcher.group(1)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + matcher.appendTail(result); return result.toString(); + } + static List splitLegacy(String text) { + List result = new ArrayList<>(); StringBuilder token = new StringBuilder(); char quote = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (quote != 0) { if (c == quote) quote = 0; else token.append(c); } + else if (c == '\'' || c == '"') quote = c; + else if (Character.isWhitespace(c)) { if (token.length() > 0) { result.add(token.toString()); token.setLength(0); } } + else token.append(c); + } + if (quote != 0) throw new IllegalArgumentException("Unterminated legacy argument quote"); + if (token.length() > 0) result.add(token.toString()); return result; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LauncherDownloads.java b/src/main/java/com/osiris/autoplug/client/launcher/LauncherDownloads.java new file mode 100644 index 00000000..b2fd1f24 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LauncherDownloads.java @@ -0,0 +1,144 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.Locale; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Integrity checked, atomic downloads. All metadata-controlled paths remain inside the selected cache. */ +public final class LauncherDownloads { + public interface Transport { InputStream open(URI uri) throws IOException; } + public static final class HttpsTransport implements Transport { + public InputStream open(URI uri) throws IOException { + URI current = uri; + for (int redirect = 0; redirect <= 5; redirect++) { + checkInterrupted(); + if (!"https".equalsIgnoreCase(current.getScheme()) || current.getUserInfo() != null) + throw new IOException("Launcher downloads require HTTPS without URL credentials"); + HttpURLConnection connection = (HttpURLConnection) current.toURL().openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(60000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "AutoPlug-NativeLauncher/10.2.0"); + int code = connection.getResponseCode(); + if (code >= 300 && code < 400) { + String location = connection.getHeaderField("Location"); connection.disconnect(); + if (location == null) throw new IOException("Download redirect has no destination"); + current = current.resolve(location); continue; + } + if (code != 200) { connection.disconnect(); throw new IOException("Launcher download returned HTTP " + code); } + return new FilterInputStream(connection.getInputStream()) { + @Override public void close() throws IOException { try { super.close(); } finally { connection.disconnect(); } } + }; + } + throw new IOException("Too many download redirects"); + } + } + private final Path root; + private final Transport transport; + public LauncherDownloads(Path root, Transport transport) throws IOException { + this.root = root.toAbsolutePath().normalize(); this.transport = transport; + rejectLinks(this.root); Files.createDirectories(this.root); + } + public Path getRoot() { return root; } + public Path resolve(String relative) throws IOException { return safeResolve(root, relative); } + public static Path safeResolve(Path directory, String relative) throws IOException { + Path base = directory.toAbsolutePath().normalize(); + if (relative == null || relative.isEmpty() || relative.contains("\\") || relative.contains(":")) + throw new IOException("Invalid metadata path"); + Path candidate = base.resolve(relative).normalize(); + if (!candidate.startsWith(base) || candidate.equals(base) || Paths.get(relative).isAbsolute()) + throw new IOException("Metadata path escapes its directory"); + rejectLinks(candidate); return candidate; + } + static void rejectLinks(Path path) throws IOException { + for (Path cursor = path.toAbsolutePath(); cursor != null; cursor = cursor.getParent()) + if (Files.isSymbolicLink(cursor)) throw new IOException("Launcher path contains a symbolic link"); + } + public JsonObject json(String url) throws IOException { + checkInterrupted(); + try (InputStream in = transport.open(URI.create(url))) { + byte[] bytes = readBounded(in, 32L * 1024 * 1024); + try { return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException e) { throw new IOException("Invalid launcher JSON metadata", e); } + } + } + public String text(String url, int limit) throws IOException { + checkInterrupted(); + try (InputStream in = transport.open(URI.create(url))) { return new String(readBounded(in, limit), StandardCharsets.UTF_8); } + } + public Path download(String url, String relative, String hash, String algorithm, long size) throws IOException { + checkInterrupted(); + Path target = resolve(relative); + if (Files.isRegularFile(target) && valid(target, hash, algorithm, size)) return target; + Files.createDirectories(target.getParent()); rejectLinks(target.getParent()); + Path temporary = target.resolveSibling(target.getFileName() + ".part-" + UUID.randomUUID()); + try { + try (InputStream in = transport.open(URI.create(url)); OutputStream out = Files.newOutputStream(temporary, StandardOpenOption.CREATE_NEW)) { + byte[] buffer = new byte[65536]; long count = 0; int n; + while ((n = in.read(buffer)) != -1) { + checkInterrupted(); + count += n; + if (count > (size >= 0 ? size : 1024L * 1024 * 1024)) throw new IOException("Download exceeds declared size or safety limit"); + out.write(buffer, 0, n); + } + } + if (!valid(temporary, hash, algorithm, size)) throw new IOException("Downloaded artifact failed integrity verification"); + checkInterrupted(); + try { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } + return target; + } finally { Files.deleteIfExists(temporary); } + } + public Path artifact(JsonObject artifact, String fallbackPath) throws IOException { + String relative = artifact.has("path") ? artifact.get("path").getAsString() : fallbackPath; + return download(artifact.get("url").getAsString(), relative, + artifact.has("sha1") ? artifact.get("sha1").getAsString() : null, "SHA-1", + artifact.has("size") ? artifact.get("size").getAsLong() : -1); + } + public static boolean valid(Path file, String expected, String algorithm, long size) throws IOException { + return Files.isRegularFile(file) && (size < 0 || Files.size(file) == size) + && (expected == null || expected.equalsIgnoreCase(hash(file, algorithm))); + } + public static String hash(Path file, String algorithm) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + try (InputStream in = Files.newInputStream(file)) { byte[] buffer = new byte[65536]; int n; while ((n = in.read(buffer)) != -1) { checkInterrupted(); digest.update(buffer, 0, n); } } + StringBuilder result = new StringBuilder(); for (byte b : digest.digest()) result.append(String.format(Locale.ROOT, "%02x", b & 255)); + return result.toString(); + } catch (java.security.NoSuchAlgorithmException e) { throw new IOException("Unavailable integrity algorithm", e); } + } + static byte[] readBounded(InputStream in, long limit) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int n; long count = 0; + while ((n = in.read(buffer)) != -1) { checkInterrupted(); count += n; if (count > limit) throw new IOException("Metadata exceeds safety limit"); out.write(buffer, 0, n); } + return out.toByteArray(); + } + public static void extractZip(Path archive, Path directory, java.util.List excludes) throws IOException { + checkInterrupted(); + rejectLinks(directory); Files.createDirectories(directory); + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry; long total = 0; int count = 0; + while ((entry = zip.getNextEntry()) != null) { + checkInterrupted(); + if (++count > 200000) throw new IOException("Archive has too many entries"); + String name = entry.getName(); boolean skip = name.startsWith("META-INF/"); + for (String prefix : excludes) skip |= name.startsWith(prefix); + if (skip) continue; + Path target = safeResolve(directory, name); + if (entry.isDirectory()) { Files.createDirectories(target); continue; } + Files.createDirectories(target.getParent()); + try (OutputStream out = Files.newOutputStream(target)) { + byte[] buffer = new byte[65536]; int n; + while ((n = zip.read(buffer)) != -1) { checkInterrupted(); total += n; if (total > 1024L * 1024 * 1024) throw new IOException("Expanded archive exceeds safety limit"); out.write(buffer, 0, n); } + } + } + } + } + public static void checkInterrupted() throws InterruptedIOException { if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("Launcher preparation was cancelled"); } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LauncherPlatform.java b/src/main/java/com/osiris/autoplug/client/launcher/LauncherPlatform.java new file mode 100644 index 00000000..045f22b9 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LauncherPlatform.java @@ -0,0 +1,41 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.Collections; +import java.util.Map; +import java.util.regex.Pattern; + +/** Mojang's ordered allow/disallow rules. An absent rule set allows; a present empty set denies. */ +public final class LauncherPlatform { + public final String os, arch, version; + public LauncherPlatform(String os, String arch, String version) { + this.os = os; this.arch = arch; this.version = version; + } + public static LauncherPlatform current() { + String name = System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT); + return new LauncherPlatform(name.contains("win") ? "windows" : name.contains("mac") ? "osx" : "linux", + System.getProperty("os.arch"), System.getProperty("os.version")); + } + public String bits() { return arch.contains("64") ? "64" : "32"; } + public boolean allows(JsonObject node) { return allows(node, Collections.emptyMap()); } + public boolean allows(JsonObject node, Map features) { + if (!node.has("rules")) return true; + boolean allow = false; + for (JsonElement element : node.getAsJsonArray("rules")) { + JsonObject rule = element.getAsJsonObject(); + boolean matches = true; + if (rule.has("os")) { + JsonObject condition = rule.getAsJsonObject("os"); + if (condition.has("name")) matches &= os.equals(condition.get("name").getAsString()); + if (condition.has("arch")) matches &= Pattern.matches(condition.get("arch").getAsString(), arch); + if (condition.has("version")) matches &= Pattern.matches(condition.get("version").getAsString(), version); + } + if (rule.has("features")) for (Map.Entry e : rule.getAsJsonObject("features").entrySet()) + matches &= features.getOrDefault(e.getKey(), false) == e.getValue().getAsBoolean(); + if (matches) allow = "allow".equals(rule.get("action").getAsString()); + } + return allow; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java b/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java new file mode 100644 index 00000000..58f8c61c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java @@ -0,0 +1,272 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.jar.*; +import java.util.regex.*; +import java.util.zip.*; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.*; + +/** Reads official Forge/NeoForge installer metadata and runs its declared client-side processors. */ +final class LoaderInstaller { + private final LauncherDownloads downloads; + private final LauncherPlatform platform; + private final JavaResolver javaResolver; + LoaderInstaller(LauncherDownloads downloads, LauncherPlatform platform, JavaResolver javaResolver) { + this.downloads = downloads; this.platform = platform; this.javaResolver = javaResolver; + } + JsonObject install(String minecraft, String loader, String requestedVersion, Path client, int javaMajor) throws Exception { + String version = requestedVersion, repository, group, artifact; + if ("FORGE".equals(loader)) { + repository = "https://maven.minecraftforge.net/"; group = "net.minecraftforge"; artifact = "forge"; + if (version == null) { + JsonObject promos = downloads.json("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json").getAsJsonObject("promos"); + String key = promos.has(minecraft + "-recommended") ? minecraft + "-recommended" : minecraft + "-latest"; + if (!promos.has(key)) throw new IOException("Forge has no release for Minecraft " + minecraft); + version = promos.get(key).getAsString(); + } + if (!version.startsWith(minecraft + "-")) version = minecraft + "-" + version; + } else { + repository = "https://maven.neoforged.net/releases/"; + // NeoForge's original 1.20.1 releases used the forge artifact; later releases use neoforge. + group = "net.neoforged"; artifact = "1.20.1".equals(minecraft) ? "forge" : "neoforge"; + if (version == null) version = selectNeoForgeVersion(downloads.text(repository + group.replace('.', '/') + "/" + artifact + "/maven-metadata.xml", 8 * 1024 * 1024), minecraft); + if (artifact.equals("forge") && !version.startsWith(minecraft + "-")) version = minecraft + "-" + version; + } + NativeLauncher.validateVersion(version); + String coordinate = group + ":" + artifact + ":" + version + ":installer"; + String installerPath = NativeLauncher.mavenPath(coordinate); + String installerUrl = repository + installerPath; + String sha1 = NativeLauncher.checksum(downloads.text(installerUrl + ".sha1", 4096)); + Path installer = downloads.download(installerUrl, "loaders/" + loader.toLowerCase(Locale.ROOT) + "/" + version + "/installer.jar", sha1, "SHA-1", -1); + Path root = downloads.resolve("loaders/" + loader.toLowerCase(Locale.ROOT) + "/" + version + "/work/.marker").getParent(); + Files.createDirectories(root); + JsonObject profile = zipJson(installer, "install_profile.json"); + JsonObject versionMetadata = profile.has("versionInfo") ? profile.getAsJsonObject("versionInfo") : zipJson(installer, + NativeLauncher.string(profile, "json", "/version.json").replaceFirst("^/", "")); + String inherited = NativeLauncher.string(versionMetadata, "inheritsFrom", NativeLauncher.string(profile, "minecraft", minecraft)); + if (!minecraft.equals(inherited)) throw new IOException("Loader installer targets a different Minecraft version"); + if (profile.has("libraries")) downloadLibraries(installer, profile.getAsJsonArray("libraries"), true); + if (profile.has("install")) { + // Old Forge installers embed the universal library instead of declaring processors. + JsonObject installation = profile.getAsJsonObject("install"); + String maven = installation.get("path").getAsString(); + Path target = downloads.resolve("libraries/" + NativeLauncher.mavenPath(maven)); + extractEntry(installer, installation.get("filePath").getAsString(), target); + } + Path completed = root.resolve("client-installation.json"); + Map data = Collections.emptyMap(); + if (profile.has("processors")) { + data = installerData(profile, installer, root, client, minecraft); + if (!completedInstallationValid(completed, sha1)) { + Path java = javaResolver.resolve(javaMajor); + for (JsonElement element : profile.getAsJsonArray("processors")) { + JsonObject processor = element.getAsJsonObject(); + if (!runsOnClient(processor)) continue; + runProcessor(processor, data, java, root); + } + } + } + if (versionMetadata.has("libraries")) downloadLibraries(installer, versionMetadata.getAsJsonArray("libraries"), false); + saveCompletedInstallation(completed, sha1, data, versionMetadata); + return versionMetadata; + } + void downloadLibraries(Path installer, JsonArray libraries, boolean deferGenerated) throws IOException { + for (JsonElement element : libraries) { + JsonObject library = element.getAsJsonObject(); if (!platform.allows(library)) continue; + JsonObject artifact = library.has("downloads") ? library.getAsJsonObject("downloads").getAsJsonObject("artifact") : null; + if (artifact == null) { + String path = NativeLauncher.mavenPath(library.get("name").getAsString()); + String repo = NativeLauncher.string(library, "url", "https://libraries.minecraft.net/"); + if (repo.startsWith("http://")) repo = "https://" + repo.substring(7); + String url = repo + (repo.endsWith("/") ? "" : "/") + path; + downloads.download(url, "libraries/" + path, NativeLauncher.checksum(downloads.text(url + ".sha1", 4096)), "SHA-1", -1); + continue; + } + String path = artifact.get("path").getAsString(); Path target = downloads.resolve("libraries/" + path); + String hash = NativeLauncher.string(artifact, "sha1", null); long size = artifact.has("size") ? artifact.get("size").getAsLong() : -1; + if (LauncherDownloads.valid(target, hash, "SHA-1", size)) continue; + if (hasEntry(installer, "maven/" + path)) { + extractEntry(installer, "maven/" + path, target); + if (!LauncherDownloads.valid(target, hash, "SHA-1", size)) { Files.deleteIfExists(target); throw new IOException("Embedded loader library failed integrity verification"); } + } else { + String url = NativeLauncher.string(artifact, "url", ""); + if (url.isEmpty()) { + // This is a processor output; it is checked after the processor completes. + if (!deferGenerated) throw new IOException("Loader processor did not produce a required client library"); + continue; + } + downloads.download(url, "libraries/" + path, hash, "SHA-1", size); + } + } + } + boolean completedInstallationValid(Path receipt, String installerSha1) throws IOException { + if (!Files.isRegularFile(receipt)) return false; + try { + JsonObject completed = NativeLauncher.readJson(receipt); + if (!installerSha1.equals(NativeLauncher.string(completed, "installerSha1", ""))) return false; + JsonObject files = completed.getAsJsonObject("files"); if (files == null || files.size() == 0) return false; + for (Map.Entry entry : files.entrySet()) + if (!LauncherDownloads.valid(downloads.resolve(entry.getKey()), entry.getValue().getAsString(), "SHA-256", -1)) return false; + return true; + } catch (RuntimeException e) { return false; } + } + void saveCompletedInstallation(Path receipt, String installerSha1, Map data, JsonObject version) throws IOException { + if (data.containsKey("PATCHED")) { + Path patched = Paths.get(data.get("PATCHED")).toAbsolutePath().normalize(); + if (!patched.startsWith(downloads.getRoot()) || !Files.isRegularFile(patched) || Files.size(patched) == 0) + throw new IOException("Loader processor did not produce the patched client"); + } + JsonObject files = new JsonObject(); + for (String value : data.values()) { + try { + Path path = Paths.get(value).toAbsolutePath().normalize(); + if (path.startsWith(downloads.getRoot()) && Files.isRegularFile(path)) + files.addProperty(downloads.getRoot().relativize(path).toString().replace(File.separatorChar, '/'), LauncherDownloads.hash(path, "SHA-256")); + } catch (InvalidPathException ignored) { /* A quoted installer scalar is not a file. */ } + } + if (version.has("libraries")) for (JsonElement e : version.getAsJsonArray("libraries")) { + JsonObject library = e.getAsJsonObject(); + if (!platform.allows(library) || !library.has("downloads") || !library.getAsJsonObject("downloads").has("artifact")) continue; + JsonObject artifact = library.getAsJsonObject("downloads").getAsJsonObject("artifact"); + if (!NativeLauncher.string(artifact, "url", "").isEmpty()) continue; + String path = "libraries/" + artifact.get("path").getAsString(); + Path file = downloads.resolve(path); if (!Files.isRegularFile(file) || Files.size(file) == 0) throw new IOException("Required generated loader library is missing or empty"); + files.addProperty(path, LauncherDownloads.hash(file, "SHA-256")); + } + JsonObject completed = new JsonObject(); completed.addProperty("installerSha1", installerSha1); completed.add("files", files); + Path temporary = receipt.resolveSibling(receipt.getFileName() + ".part-" + UUID.randomUUID()); + try { + Files.write(temporary, completed.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW); + Files.move(temporary, receipt, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(temporary); } + } + Map installerData(JsonObject profile, Path installer, Path root, Path client, String minecraft) throws IOException { + Map data = new HashMap<>(); + // ROOT is the installation root, not the processor working directory. Current NeoForge + // extracts embedded Minecraft libraries into {ROOT}/libraries, which must match LIBRARY_DIR. + data.put("SIDE", "client"); data.put("ROOT", downloads.getRoot().toString()); data.put("LIBRARY_DIR", downloads.resolve("libraries/.marker").getParent().toString()); + data.put("INSTALLER", installer.toString()); data.put("MINECRAFT_JAR", client.toString()); data.put("MINECRAFT_VERSION", minecraft); + if (profile.has("data")) for (Map.Entry entry : profile.getAsJsonObject("data").entrySet()) { + JsonObject sides = entry.getValue().getAsJsonObject(); if (!sides.has("client")) continue; + String value = sides.get("client").getAsString(); + if (value.startsWith("/") && !value.startsWith("//")) { + Path target = LauncherDownloads.safeResolve(root.resolve("installer-data"), value.substring(1)); + extractEntry(installer, value.substring(1), target); value = target.toString(); + } else value = expression(value, data); + data.put(entry.getKey(), value); + } + return data; + } + String expression(String value, Map data) throws IOException { + if (value.startsWith("'") && value.endsWith("'")) value = value.substring(1, value.length() - 1); + Matcher placeholders = Pattern.compile("\\{([^}]+)}").matcher(value); StringBuffer output = new StringBuffer(); + while (placeholders.find()) { + String replacement = data.get(placeholders.group(1)); + if (replacement == null) throw new IOException("Unknown installer variable: " + placeholders.group(1)); + placeholders.appendReplacement(output, Matcher.quoteReplacement(replacement)); + } + placeholders.appendTail(output); value = output.toString(); + Matcher maven = Pattern.compile("\\[([^]\\[]+)]").matcher(value); output = new StringBuffer(); + while (maven.find()) maven.appendReplacement(output, Matcher.quoteReplacement(downloads.resolve("libraries/" + NativeLauncher.mavenPath(maven.group(1))).toString())); + maven.appendTail(output); return output.toString(); + } + void runProcessor(JsonObject processor, Map data, Path java, Path root) throws Exception { + if (outputsValid(processor, data)) return; + Path jar = downloads.resolve("libraries/" + NativeLauncher.mavenPath(processor.get("jar").getAsString())); + if (!Files.isRegularFile(jar)) throw new IOException("Installer processor library is missing"); + List classpath = new ArrayList<>(); classpath.add(jar.toString()); + if (processor.has("classpath")) for (JsonElement dependency : processor.getAsJsonArray("classpath")) { + Path file = downloads.resolve("libraries/" + NativeLauncher.mavenPath(dependency.getAsString())); + if (!Files.isRegularFile(file)) throw new IOException("Installer processor dependency is missing"); classpath.add(file.toString()); + } + String main; + try (JarFile jarFile = new JarFile(jar.toFile())) { + if (jarFile.getManifest() == null) throw new IOException("Installer processor has no manifest"); + main = jarFile.getManifest().getMainAttributes().getValue(Attributes.Name.MAIN_CLASS); + } + if (main == null || !main.matches("[A-Za-z0-9_.$]+")) throw new IOException("Invalid installer processor main class"); + List command = new ArrayList<>(Arrays.asList(java.toString(), "-cp", String.join(File.pathSeparator, classpath), main)); + if (processor.has("args")) for (JsonElement argument : processor.getAsJsonArray("args")) command.add(expression(argument.getAsString(), data)); + Path log = root.resolve("processor-" + UUID.randomUUID() + ".log"); + LauncherDownloads.checkInterrupted(); + Process process = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + try { + if (!process.waitFor(10, TimeUnit.MINUTES)) throw new IOException("Loader processor timed out"); + if (process.exitValue() != 0) throw new IOException("Loader processor failed (exit " + process.exitValue() + "); see " + log.getFileName()); + if (processor.has("outputs") && !outputsValid(processor, data)) throw new IOException("Loader processor output failed integrity verification"); + } catch (InterruptedException cancelled) { Thread.currentThread().interrupt(); throw cancelled; } + finally { if (process.isAlive()) { process.descendants().forEach(child -> { if (child.isAlive()) child.destroyForcibly(); }); process.destroyForcibly(); } } + } + boolean outputsValid(JsonObject processor, Map data) throws IOException { + if (!processor.has("outputs") || processor.getAsJsonObject("outputs").size() == 0) return false; + boolean valid = true; + for (Map.Entry output : processor.getAsJsonObject("outputs").entrySet()) { + Path file = Paths.get(expression(output.getKey(), data)).toAbsolutePath().normalize(); + if (!file.startsWith(downloads.getRoot())) throw new IOException("Installer output escapes the launcher cache"); + LauncherDownloads.rejectLinks(file); + String expected = expression(output.getValue().getAsString(), data); + if (!expected.matches("[a-fA-F0-9]{40}")) throw new IOException("Invalid installer output checksum"); + if (!LauncherDownloads.valid(file, expected, "SHA-1", -1)) valid = false; + } + return valid; + } + static boolean runsOnClient(JsonObject processor) { + if (!processor.has("sides")) return true; + for (JsonElement side : processor.getAsJsonArray("sides")) if ("client".equals(side.getAsString())) return true; + return false; + } + static JsonObject zipJson(Path archive, String name) throws IOException { + try (ZipFile zip = new ZipFile(archive.toFile())) { + ZipEntry entry = zip.getEntry(name); if (entry == null) throw new IOException("Loader installer metadata is missing: " + name); + try (InputStream input = zip.getInputStream(entry)) { return JsonParser.parseString(new String(LauncherDownloads.readBounded(input, 32 * 1024 * 1024), StandardCharsets.UTF_8)).getAsJsonObject(); } + } + } + static boolean hasEntry(Path archive, String name) throws IOException { try (ZipFile zip = new ZipFile(archive.toFile())) { return zip.getEntry(name) != null; } } + static void extractEntry(Path archive, String name, Path destination) throws IOException { + LauncherDownloads.rejectLinks(destination); Files.createDirectories(destination.getParent()); + try (ZipFile zip = new ZipFile(archive.toFile())) { + ZipEntry entry = zip.getEntry(name); if (entry == null || entry.isDirectory()) throw new IOException("Loader installer entry is missing"); + try (InputStream in = zip.getInputStream(entry); OutputStream out = Files.newOutputStream(destination)) { + byte[] buffer = new byte[65536]; long count = 0; int n; + while ((n = in.read(buffer)) != -1) { count += n; if (count > 256L * 1024 * 1024) throw new IOException("Embedded loader library exceeds safety limit"); out.write(buffer, 0, n); } + } + } + } + static String selectNeoForgeVersion(String xml, String minecraft) throws Exception { + String[] parts = minecraft.split("\\."); + if (parts.length < 2 || !parts[0].matches("[0-9]+") || !parts[1].matches("[0-9]+")) throw new IOException("Unsupported NeoForge game version"); + String prefix = "1.20.1".equals(minecraft) ? "1.20.1-" : "1".equals(parts[0]) + ? parts[1] + "." + (parts.length > 2 ? parts[2] : "0") + "." + : parts[0] + "." + parts[1] + "." + (parts.length > 2 ? parts[2] : "0") + "."; + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + NodeList versions = document.getElementsByTagName("version"); String selected = null, preview = null; + for (int i = 0; i < versions.getLength(); i++) { + String candidate = versions.item(i).getTextContent().trim(); + if (!candidate.startsWith(prefix)) continue; + if (candidate.matches(".*-(alpha|beta|rc).*")) { if (preview == null || compareVersions(candidate, preview) > 0) preview = candidate; } + else if (selected == null || compareVersions(candidate, selected) > 0) selected = candidate; + } + if (selected == null) selected = preview; + if (selected == null) throw new IOException("NeoForge has no release for Minecraft " + minecraft); return selected; + } + static int compareVersions(String a, String b) { + String[] left = a.split("[.-]"), right = b.split("[.-]"); + for (int i = 0; i < Math.max(left.length, right.length); i++) { + String x = i < left.length ? left[i] : "", y = i < right.length ? right[i] : ""; int result; + try { result = Integer.compare(Integer.parseInt(x), Integer.parseInt(y)); } catch (NumberFormatException e) { result = x.compareTo(y); } + if (result != 0) return result; + } + return 0; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntime.java b/src/main/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntime.java new file mode 100644 index 00000000..1ec3a198 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntime.java @@ -0,0 +1,159 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import com.osiris.autoplug.client.tasks.updater.java.AdoptV3API; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.regex.*; +import java.util.zip.GZIPInputStream; + +/** Reuses AutoPlug's Adoptium protocol in separate per-major installations, without changing server updater settings. */ +public final class ManagedJavaRuntime implements JavaResolver { + private final Path root; + private final LauncherDownloads downloads; + private final Map configured = new HashMap<>(); + public ManagedJavaRuntime(Path root) throws IOException { this(root, new LauncherDownloads.HttpsTransport()); } + public ManagedJavaRuntime(Path root, LauncherDownloads.Transport transport) throws IOException { + this.root = root.toAbsolutePath().normalize(); this.downloads = new LauncherDownloads(this.root, transport); + } + public synchronized void configure(int major, Path executable) { configured.put(major, executable.toAbsolutePath().normalize()); } + @Override public synchronized Path resolve(int major) throws Exception { + if (major < 8 || major > 99) throw new IOException("Unsupported Java major version"); + Path setting = configured.get(major); + if (setting != null) { verifyMajor(setting, major); return setting; } + String executable = LauncherPlatform.current().os.equals("windows") ? "java.exe" : "java"; + Path current = Paths.get(System.getProperty("java.home"), "bin", executable); + if (javaMajorFromRelease(Paths.get(System.getProperty("java.home"), "release")) == major && Files.isRegularFile(current)) return current; + Path installation = root.resolve(Integer.toString(major)); + Path installed = findExecutable(installation, major, executable); + if (installed != null) { verifyMajor(installed, major); return installed; } + + AdoptV3API api = new AdoptV3API(); + LauncherPlatform platform = LauncherPlatform.current(); + AdoptV3API.OperatingSystemType os = platform.os.equals("windows") ? AdoptV3API.OperatingSystemType.WINDOWS + : platform.os.equals("osx") ? AdoptV3API.OperatingSystemType.MAC : AdoptV3API.OperatingSystemType.LINUX; + AdoptV3API.OperatingSystemArchitectureType arch = architecture(platform.arch); + JsonObject release = null; + // Same release discovery API as TaskJavaUpdater, bounded so an unsupported major cannot poll indefinitely. + for (int page = 0; page < 20 && release == null; page++) { + JsonObject response; + try { response = downloads.json(api.getReleasesUrl(page, arch, false, AdoptV3API.ImageType.JDK, + true, major == 8 || major == 11 || major == 17 || major == 21 || major == 25, + os, 50, AdoptV3API.VendorProjectType.JDK, AdoptV3API.ReleaseType.GENERAL_AVAILABILITY)); } + catch (IOException e) { if (page == 0) throw e; break; } + JsonArray versions = response.getAsJsonArray("versions"); + if (versions == null || versions.size() == 0) break; + for (JsonElement item : versions) if (item.getAsJsonObject().get("major").getAsInt() == major) { release = item.getAsJsonObject(); break; } + } + if (release == null) throw new IOException("No Java " + major + " release available; configure an installed Java path in Settings"); + String url = api.getVersionInformationUrl(release.get("semver").getAsString(), arch, false, AdoptV3API.ImageType.JDK, + true, major == 8 || major == 11 || major == 17 || major == 21 || major == 25, + os, 20, AdoptV3API.VendorProjectType.JDK, AdoptV3API.ReleaseType.GENERAL_AVAILABILITY); + JsonArray details = JsonParser.parseString(downloads.text(url, 16 * 1024 * 1024)).getAsJsonArray(); + JsonObject binaryPackage = null; + for (JsonElement item : details) { + JsonObject detail = item.getAsJsonObject(); + if (detail.has("version_data") && detail.getAsJsonObject("version_data").get("major").getAsInt() != major) continue; + JsonArray binaries = detail.getAsJsonArray("binaries"); + if (binaries != null && binaries.size() > 0) { binaryPackage = binaries.get(0).getAsJsonObject().getAsJsonObject("package"); break; } + } + if (binaryPackage == null) throw new IOException("No matching Java binary found"); + String filename = binaryPackage.get("name").getAsString(); + String hash = binaryPackage.get("checksum").getAsString(); + if (!hash.matches("[a-fA-F0-9]{64}")) throw new IOException("Invalid Java SHA-256 metadata"); + Path archive = downloads.download(binaryPackage.get("link").getAsString(), "archives/" + filename, hash, "SHA-256", binaryPackage.get("size").getAsLong()); + Path staging = downloads.resolve(major + "/.staging-" + UUID.randomUUID()); + Files.createDirectories(staging); + if (filename.endsWith(".zip")) LauncherDownloads.extractZip(archive, staging, Collections.emptyList()); + else if (filename.endsWith(".tar.gz")) extractTarGz(archive, staging); + else throw new IOException("Unsupported Java archive format"); + installed = findExecutable(staging, major, executable); + if (installed == null) throw new IOException("Java archive contains no matching runtime"); + if (!platform.os.equals("windows")) installed.toFile().setExecutable(true, false); + verifyMajor(installed, major); + Path relativeExecutable = staging.relativize(installed); + Path finalDirectory = downloads.resolve(major + "/" + hash); + if (Files.exists(finalDirectory)) { + Path previous = finalDirectory.resolve(relativeExecutable); verifyMajor(previous, major); return previous; + } + try { Files.move(staging, finalDirectory, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException e) { Files.move(staging, finalDirectory); } + return finalDirectory.resolve(relativeExecutable); + } + static AdoptV3API.OperatingSystemArchitectureType architecture(String value) throws IOException { + for (AdoptV3API.OperatingSystemArchitectureType arch : AdoptV3API.OperatingSystemArchitectureType.values()) + if (arch.name().equalsIgnoreCase(value)) return arch; + throw new IOException("Unsupported Java runtime architecture: " + value); + } + static Path findExecutable(Path folder, int major, String executable) throws IOException { + if (!Files.isDirectory(folder)) return null; + try (java.util.stream.Stream files = Files.walk(folder, 5)) { + Iterator iterator = files.filter(path -> !folder.relativize(path).toString().contains(".staging-") + && path.getFileName().toString().equals("release") && Files.isRegularFile(path)).iterator(); + while (iterator.hasNext()) { Path release = iterator.next(); Path candidate = release.getParent().resolve("bin/" + executable); + if (javaMajorFromRelease(release) == major && Files.isRegularFile(candidate)) return candidate; } + } + return null; + } + static int javaMajorFromRelease(Path release) throws IOException { + if (!Files.isRegularFile(release)) return -1; + Properties properties = new Properties(); try (Reader in = Files.newBufferedReader(release, StandardCharsets.UTF_8)) { properties.load(in); } + String version = properties.getProperty("JAVA_VERSION", "").replace("\"", ""); + Matcher matcher = Pattern.compile("^(?:1\\.)?(\\d+)").matcher(version); + return matcher.find() ? Integer.parseInt(matcher.group(1)) : -1; + } + public static void verifyMajor(Path executable, int expected) throws IOException, InterruptedException { + if (!Files.isRegularFile(executable)) throw new IOException("Configured Java executable does not exist"); + Process process = new ProcessBuilder(executable.toString(), "-version").redirectErrorStream(true).start(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { process.destroyForcibly(); throw new IOException("Java version probe timed out"); } + String text; try (InputStream in = process.getInputStream()) { text = new String(LauncherDownloads.readBounded(in, 16384), StandardCharsets.UTF_8); } + Matcher matcher = Pattern.compile("version \\\"(?:1\\.)?(\\d+)").matcher(text); + if (process.exitValue() != 0 || !matcher.find() || Integer.parseInt(matcher.group(1)) != expected) + throw new IOException("Selected runtime is not Java " + expected); + } + static void extractTarGz(Path archive, Path directory) throws IOException { + List pendingLinks = new ArrayList<>(); List symbolic = new ArrayList<>(); + try (TarArchiveInputStream tar = new TarArchiveInputStream(new GZIPInputStream(Files.newInputStream(archive)))) { + TarArchiveEntry entry; long total = 0; int count = 0; + while ((entry = tar.getNextTarEntry()) != null) { + if (++count > 200000) throw new IOException("Runtime archive has too many entries"); + Path target = LauncherDownloads.safeResolve(directory, entry.getName()); + if (entry.isDirectory()) { Files.createDirectories(target); continue; } + if (entry.isLink() || entry.isSymbolicLink()) { + String name = entry.getLinkName(); + if (name.contains(":") || name.contains("\\") || Paths.get(name).isAbsolute()) throw new IOException("Unsafe runtime archive link"); + Path linkTarget = (entry.isSymbolicLink() ? target.getParent() : directory).resolve(name).toAbsolutePath().normalize(); + if (!linkTarget.startsWith(directory.toAbsolutePath().normalize())) throw new IOException("Runtime link escapes installation"); + pendingLinks.add(new Path[]{target, linkTarget}); symbolic.add(entry.isSymbolicLink()); continue; + } + if (!entry.isFile()) throw new IOException("Unsupported runtime archive entry"); + Files.createDirectories(target.getParent()); + try (OutputStream out = Files.newOutputStream(target)) { + byte[] buffer = new byte[65536]; int n; + while ((n = tar.read(buffer)) != -1) { total += n; if (total > 1024L * 1024 * 1024) throw new IOException("Runtime archive exceeds safety limit"); out.write(buffer, 0, n); } + } + if ((entry.getMode() & 0111) != 0) target.toFile().setExecutable(true, false); + } + } + // Create links after ordinary files so archive entries cannot write through them. + for (int i = 0; i < pendingLinks.size(); i++) { + Path target = pendingLinks.get(i)[0], linkTarget = pendingLinks.get(i)[1]; + LauncherDownloads.rejectLinks(target); LauncherDownloads.rejectLinks(linkTarget); + if (!Files.exists(linkTarget)) throw new IOException("Runtime link target is missing"); + Files.createDirectories(target.getParent()); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Duplicate runtime archive link"); + if (symbolic.get(i)) { + try { Files.createSymbolicLink(target, target.getParent().relativize(linkTarget)); } + catch (IOException | UnsupportedOperationException e) { + if (!Files.isRegularFile(linkTarget)) throw new IOException("Cannot create runtime directory link", e); + Files.copy(linkTarget, target); + } + } else Files.createLink(target, linkTarget); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftOAuth.java b/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftOAuth.java new file mode 100644 index 00000000..2f9a011b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftOAuth.java @@ -0,0 +1,175 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import java.util.function.BooleanSupplier; + +/** Public-client OAuth device authorization, Xbox Live/XSTS and Minecraft Services. Tokens remain in memory. */ +public final class MicrosoftOAuth { + private static final String AUTHORITY = "https://login.microsoftonline.com/consumers/oauth2/v2.0/"; + private static final String SCOPES = "XboxLive.signin offline_access"; + public interface Transport { Response request(String method, URI uri, Map headers, byte[] body) throws IOException; } + public interface Sleeper { void sleep(long milliseconds) throws InterruptedException; } + public static final class Response { + public final int status; public final JsonObject json; + public Response(int status, JsonObject json) { this.status = status; this.json = json; } + @Override public String toString() { return "OAuthResponse{HTTP " + status + "}"; } + } + public static final class DeviceAuthorization { + private final String userCode, deviceCode; + private final URI verificationUri; + private final Instant expiresAt; + private final int intervalSeconds; + private final String clientId; + private DeviceAuthorization(String userCode, String deviceCode, URI uri, Instant expiresAt, int interval, String clientId) { + this.userCode = userCode; this.deviceCode = deviceCode; this.verificationUri = uri; + this.expiresAt = expiresAt; this.intervalSeconds = interval; this.clientId = clientId; + } + public String getUserCode() { return userCode; } + public URI getVerificationUri() { return verificationUri; } + public Instant getExpiresAt() { return expiresAt; } + public int getIntervalSeconds() { return intervalSeconds; } + @Override public String toString() { return "DeviceAuthorization{expires " + expiresAt + "}"; } + } + public static final class AuthenticationException extends IOException { + public AuthenticationException(String reason) { super(reason); } + } + public static final class HttpsTransport implements Transport { + @Override public Response request(String method, URI uri, Map headers, byte[] body) throws IOException { + if (!"https".equals(uri.getScheme()) || uri.getUserInfo() != null) throw new IOException("Authentication requires HTTPS"); + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(30000); connection.setInstanceFollowRedirects(false); + connection.setRequestMethod(method); for (Map.Entry header : headers.entrySet()) connection.setRequestProperty(header.getKey(), header.getValue()); + try { + if (body != null) { connection.setDoOutput(true); try (OutputStream out = connection.getOutputStream()) { out.write(body); } } + int status = connection.getResponseCode(); + InputStream stream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); + JsonObject json = new JsonObject(); + if (stream != null) try (InputStream in = stream) { + try { json = JsonParser.parseString(new String(LauncherDownloads.readBounded(in, 1024 * 1024), StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException e) { throw new AuthenticationException("Authentication service returned invalid JSON"); } + } + return new Response(status, json); + } finally { connection.disconnect(); } + } + } + private final String clientId; + private final Transport transport; + private final Clock clock; + private final Sleeper sleeper; + private volatile String refreshToken; + private volatile Instant accessTokenExpiresAt; + public MicrosoftOAuth(String clientId) { this(clientId, new HttpsTransport(), Clock.systemUTC(), Thread::sleep); } + public MicrosoftOAuth(String clientId, Transport transport, Clock clock, Sleeper sleeper) { + if (clientId == null || !clientId.matches("[A-Za-z0-9-]{8,128}")) throw new IllegalArgumentException("Configure your registered Microsoft OAuth public-client application ID"); + this.clientId = clientId; this.transport = Objects.requireNonNull(transport); this.clock = Objects.requireNonNull(clock); this.sleeper = Objects.requireNonNull(sleeper); + } + /** Call only in response to the account owner's explicit sign-in action. */ + public DeviceAuthorization beginDeviceAuthorization() throws IOException { + Response response = form(AUTHORITY + "devicecode", pairs("client_id", clientId, "scope", SCOPES)); + requireSuccess(response, "Microsoft device authorization"); + try { + String device = response.json.get("device_code").getAsString(); String user = response.json.get("user_code").getAsString(); + URI verification = URI.create(response.json.get("verification_uri").getAsString()); + if (!"https".equals(verification.getScheme())) throw new AuthenticationException("Invalid Microsoft verification URI"); + int expiry = response.json.get("expires_in").getAsInt(); if (expiry < 1 || expiry > 86400) throw new AuthenticationException("Invalid device authorization expiry"); + int interval = response.json.has("interval") ? Math.max(1, response.json.get("interval").getAsInt()) : 5; + return new DeviceAuthorization(user, device, verification, clock.instant().plusSeconds(expiry), interval, clientId); + } catch (RuntimeException e) { throw new AuthenticationException("Incomplete Microsoft device authorization response"); } + } + public LaunchAccount awaitMinecraftAccount(DeviceAuthorization authorization, BooleanSupplier cancelled) throws IOException, InterruptedException { + Objects.requireNonNull(authorization); Objects.requireNonNull(cancelled); + if (!clientId.equals(authorization.clientId)) throw new AuthenticationException("Device authorization belongs to another application"); + int interval = authorization.intervalSeconds; + while (clock.instant().isBefore(authorization.expiresAt)) { + waitCancellable(Math.min(interval * 1000L, Math.max(0, Duration.between(clock.instant(), authorization.expiresAt).toMillis())), cancelled); + if (!clock.instant().isBefore(authorization.expiresAt)) break; + Response response = form(AUTHORITY + "token", pairs("grant_type", "urn:ietf:params:oauth:grant-type:device_code", "client_id", clientId, "device_code", authorization.deviceCode)); + if (response.status >= 200 && response.status < 300) return exchangeMinecraft(response.json, null); + String error = NativeLauncher.string(response.json, "error", "unknown_error"); + if ("authorization_pending".equals(error)) continue; + if ("slow_down".equals(error)) { interval += 5; continue; } + if ("authorization_declined".equals(error) || "access_denied".equals(error)) throw new AuthenticationException("Microsoft sign-in was declined"); + if ("expired_token".equals(error) || "bad_verification_code".equals(error)) throw new AuthenticationException("Microsoft device authorization expired or became invalid"); + throw new AuthenticationException("Microsoft token request failed (HTTP " + response.status + ")"); + } + throw new AuthenticationException("Microsoft device authorization expired"); + } + public LaunchAccount refresh(String token) throws IOException { + if (token == null || token.isEmpty()) throw new AuthenticationException("No Microsoft refresh token is available"); + Response response = form(AUTHORITY + "token", pairs("grant_type", "refresh_token", "client_id", clientId, "refresh_token", token, "scope", SCOPES)); + requireSuccess(response, "Microsoft refresh"); return exchangeMinecraft(response.json, token); + } + /** Storage is the caller's responsibility. This class never writes credentials to disk. */ + public String getRefreshToken() { return refreshToken; } + public Instant getAccessTokenExpiresAt() { return accessTokenExpiresAt; } + public void clearSession() { refreshToken = null; accessTokenExpiresAt = null; } + private LaunchAccount exchangeMinecraft(JsonObject microsoft, String previousRefresh) throws IOException { + String microsoftToken = required(microsoft, "access_token", "Microsoft access token is missing"); + JsonObject userProperties = new JsonObject(); userProperties.addProperty("AuthMethod", "RPS"); + userProperties.addProperty("SiteName", "user.auth.xboxlive.com"); userProperties.addProperty("RpsTicket", "d=" + microsoftToken); + JsonObject xboxBody = new JsonObject(); xboxBody.add("Properties", userProperties); xboxBody.addProperty("RelyingParty", "http://auth.xboxlive.com"); xboxBody.addProperty("TokenType", "JWT"); + Response xbox = jsonPost("https://user.auth.xboxlive.com/user/authenticate", xboxBody); requireSuccess(xbox, "Xbox Live user authentication"); + String xboxToken = required(xbox.json, "Token", "Xbox Live user token is missing"); + JsonObject xstsProperties = new JsonObject(); xstsProperties.addProperty("SandboxId", "RETAIL"); + JsonArray userTokens = new JsonArray(); userTokens.add(xboxToken); xstsProperties.add("UserTokens", userTokens); + JsonObject xstsBody = new JsonObject(); xstsBody.add("Properties", xstsProperties); xstsBody.addProperty("RelyingParty", "rp://api.minecraftservices.com/"); xstsBody.addProperty("TokenType", "JWT"); + Response xsts = jsonPost("https://xsts.auth.xboxlive.com/xsts/authorize", xstsBody); + if (xsts.status == 401 && xsts.json.has("XErr")) { + long code = xsts.json.get("XErr").getAsLong(); + if (code == 2148916233L) throw new AuthenticationException("This Microsoft account has no Xbox profile; create one before signing in"); + if (code == 2148916238L) throw new AuthenticationException("This child account requires its Xbox family settings to allow access"); + throw new AuthenticationException("Xbox account settings prevent Minecraft authentication"); + } + requireSuccess(xsts, "Xbox XSTS authorization"); + JsonObject claim; + try { claim = xsts.json.getAsJsonObject("DisplayClaims").getAsJsonArray("xui").get(0).getAsJsonObject(); } + catch (RuntimeException e) { throw new AuthenticationException("Xbox XSTS user identity is missing"); } + String userHash = required(claim, "uhs", "Xbox XSTS user hash is missing"); + JsonObject login = new JsonObject(); login.addProperty("identityToken", "XBL3.0 x=" + userHash + ";" + required(xsts.json, "Token", "Xbox XSTS token is missing")); + Response minecraft = jsonPost("https://api.minecraftservices.com/authentication/login_with_xbox", login); requireSuccess(minecraft, "Minecraft authentication"); + String access = required(minecraft.json, "access_token", "Minecraft access token is missing"); + Response entitlements = get("https://api.minecraftservices.com/entitlements/mcstore", access); requireSuccess(entitlements, "Minecraft entitlement check"); + boolean ownsGame = false; + if (entitlements.json.has("items")) for (JsonElement item : entitlements.json.getAsJsonArray("items")) { + String name = NativeLauncher.string(item.getAsJsonObject(), "name", ""); + ownsGame |= name.equals("game_minecraft") || name.equals("product_minecraft"); + } + if (!ownsGame) throw new AuthenticationException("This account does not have a Minecraft Java entitlement"); + Response profile = get("https://api.minecraftservices.com/minecraft/profile", access); requireSuccess(profile, "Minecraft profile lookup"); + String name = required(profile.json, "name", "Minecraft profile name is missing"); String uuid = required(profile.json, "id", "Minecraft profile UUID is missing"); + LaunchAccount account; + try { account = LaunchAccount.microsoft(name, uuid, access, NativeLauncher.string(claim, "xid", ""), clientId); } + catch (IllegalArgumentException e) { throw new AuthenticationException("Minecraft profile identity is invalid"); } + refreshToken = NativeLauncher.string(microsoft, "refresh_token", previousRefresh); + int seconds = minecraft.json.has("expires_in") ? minecraft.json.get("expires_in").getAsInt() : 3600; + accessTokenExpiresAt = clock.instant().plusSeconds(Math.max(1, seconds)); + return account; + } + private void waitCancellable(long milliseconds, BooleanSupplier cancelled) throws InterruptedException, AuthenticationException { + for (long remaining = milliseconds; remaining > 0; remaining -= 250) { + if (cancelled.getAsBoolean()) throw new AuthenticationException("Microsoft sign-in cancelled"); + sleeper.sleep(Math.min(remaining, 250)); + } + if (cancelled.getAsBoolean()) throw new AuthenticationException("Microsoft sign-in cancelled"); + } + private Response form(String url, Map parameters) throws IOException { + StringBuilder body = new StringBuilder(); + for (Map.Entry item : parameters.entrySet()) { if (body.length() > 0) body.append('&'); body.append(URLEncoder.encode(item.getKey(), "UTF-8")).append('=').append(URLEncoder.encode(item.getValue(), "UTF-8")); } + return transport.request("POST", URI.create(url), pairs("Content-Type", "application/x-www-form-urlencoded", "Accept", "application/json"), body.toString().getBytes(StandardCharsets.UTF_8)); + } + private Response jsonPost(String url, JsonObject json) throws IOException { + Map headers = pairs("Content-Type", "application/json", "Accept", "application/json"); + if (URI.create(url).getHost().endsWith(".xboxlive.com")) headers.put("x-xbl-contract-version", "1"); + return transport.request("POST", URI.create(url), headers, json.toString().getBytes(StandardCharsets.UTF_8)); + } + private Response get(String url, String token) throws IOException { return transport.request("GET", URI.create(url), pairs("Authorization", "Bearer " + token, "Accept", "application/json"), null); } + private static Map pairs(String... strings) { Map result = new LinkedHashMap<>(); for (int i = 0; i < strings.length; i += 2) result.put(strings[i], strings[i + 1]); return result; } + private static void requireSuccess(Response response, String step) throws AuthenticationException { if (response.status < 200 || response.status >= 300) throw new AuthenticationException(step + " failed (HTTP " + response.status + ")"); } + private static String required(JsonObject json, String key, String reason) throws AuthenticationException { if (!json.has(key) || json.get(key).isJsonNull() || json.get(key).getAsString().isEmpty()) throw new AuthenticationException(reason); return json.get(key).getAsString(); } + @Override public String toString() { return "MicrosoftOAuth{public client}"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/NativeLauncher.java b/src/main/java/com/osiris/autoplug/client/launcher/NativeLauncher.java new file mode 100644 index 00000000..a06a940f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/NativeLauncher.java @@ -0,0 +1,191 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +/** Clean-room launcher based on official Mojang and loader JSON protocols, without launcher code imports. */ +public final class NativeLauncher { + public static final String VERSION_MANIFEST = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + private final LauncherDownloads downloads; + private final LauncherPlatform platform; + private final JavaResolver javaResolver; + public NativeLauncher(Path cacheRoot) throws IOException { + this(cacheRoot, new LauncherDownloads.HttpsTransport(), LauncherPlatform.current(), null); + } + public NativeLauncher(Path cacheRoot, LauncherDownloads.Transport transport, LauncherPlatform platform, JavaResolver resolver) throws IOException { + this.downloads = new LauncherDownloads(cacheRoot, transport); this.platform = platform; + this.javaResolver = resolver == null ? new ManagedJavaRuntime(cacheRoot.resolve("runtime")) : resolver; + } + public Path resolveJava(LaunchPlan plan) throws Exception { return javaResolver.resolve(plan.getJavaMajor()); } + public LaunchPlan prepare(String gameVersion, String loader, Path gameDirectory, LaunchAccount account) throws Exception { + return prepare(gameVersion, loader, null, gameDirectory, account); + } + public LaunchPlan prepare(String gameVersion, String loader, String loaderVersion, Path gameDirectory, LaunchAccount account) throws Exception { + validateVersion(gameVersion); + JsonObject manifest = downloads.json(VERSION_MANIFEST); JsonObject entry = null; + for (JsonElement e : manifest.getAsJsonArray("versions")) if (gameVersion.equals(e.getAsJsonObject().get("id").getAsString())) { entry = e.getAsJsonObject(); break; } + if (entry == null) throw new IOException("Minecraft version is absent from Mojang's official manifest"); + Path metadataFile = downloads.download(entry.get("url").getAsString(), "versions/" + gameVersion + "/" + gameVersion + ".json", + string(entry, "sha1", null), "SHA-1", -1); + JsonObject base = readJson(metadataFile); + String type = loader == null ? "VANILLA" : loader.toUpperCase(Locale.ROOT); + JsonObject combined = base; + if ("FABRIC".equals(type) || "QUILT".equals(type)) { + String endpoint = "FABRIC".equals(type) ? "https://meta.fabricmc.net/v2" : "https://meta.quiltmc.org/v3"; + if (loaderVersion == null) { + JsonArray versions = JsonParser.parseString(downloads.text(endpoint + "/versions/loader/" + gameVersion, 8 * 1024 * 1024)).getAsJsonArray(); + if (versions.size() == 0) throw new IOException("Loader does not support this Minecraft version"); + JsonObject selected = versions.get(0).getAsJsonObject(); + for (JsonElement e : versions) if (e.getAsJsonObject().getAsJsonObject("loader").has("stable") + && e.getAsJsonObject().getAsJsonObject("loader").get("stable").getAsBoolean()) { selected = e.getAsJsonObject(); break; } + loaderVersion = selected.getAsJsonObject("loader").get("version").getAsString(); + } + validateVersion(loaderVersion); + JsonObject extra = downloads.json(endpoint + "/versions/loader/" + gameVersion + "/" + loaderVersion + "/profile/json"); + combined = merge(base, extra); + } else if ("FORGE".equals(type) || "NEOFORGE".equals(type)) { + // Installer processors need only the official client JAR, not a game account or running game. + Path client = client(base); + LoaderInstaller installer = new LoaderInstaller(downloads, platform, javaResolver); + combined = merge(base, installer.install(gameVersion, type, loaderVersion, client, + base.has("javaVersion") ? base.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8)); + } else if (!"VANILLA".equals(type) && !"NONE".equals(type)) throw new IOException("Not a supported client loader: " + type); + return prepareMetadata(combined, gameVersion, gameDirectory, account); + } + /** Injected metadata/transport is useful for protocol fixtures; normal callers use prepare(). */ + public LaunchPlan prepareMetadata(JsonObject metadata, String gameVersion, Path gameDirectory, LaunchAccount account) throws Exception { + validateVersion(gameVersion); validateVersion(metadata.get("id").getAsString()); + Path directory = gameDirectory.toAbsolutePath().normalize(); LauncherDownloads.rejectLinks(directory); Files.createDirectories(directory); + Path client = client(metadata); + List classpath = new ArrayList<>(); + Path natives = directory.resolve("autoplug/natives/" + metadata.get("id").getAsString() + "-" + platform.os + "-" + platform.arch.replaceAll("[^A-Za-z0-9_-]", "")); + LauncherDownloads.rejectLinks(natives); Files.createDirectories(natives); + if (metadata.has("libraries")) for (JsonElement e : metadata.getAsJsonArray("libraries")) { + JsonObject library = e.getAsJsonObject(); if (!platform.allows(library)) continue; + Path artifact = library(library); if (artifact != null) { + classpath.add(artifact); + if (string(library, "name", "").contains(":natives-")) LauncherDownloads.extractZip(artifact, natives, Collections.emptyList()); + } + if (library.has("natives") && library.getAsJsonObject("natives").has(platform.os)) { + String classifier = library.getAsJsonObject("natives").get(platform.os).getAsString().replace("${arch}", platform.bits()); + JsonObject classifiers = library.getAsJsonObject("downloads").getAsJsonObject("classifiers"); + if (!classifiers.has(classifier)) throw new IOException("Required native classifier is missing: " + classifier); + Path nativeJar = artifact(classifiers.getAsJsonObject(classifier)); + List excludes = new ArrayList<>(); + if (library.has("extract") && library.getAsJsonObject("extract").has("exclude")) + for (JsonElement excluded : library.getAsJsonObject("extract").getAsJsonArray("exclude")) excludes.add(excluded.getAsString()); + LauncherDownloads.extractZip(nativeJar, natives, excludes); + } + } + classpath.add(client); + Path assets = downloads.resolve("assets/objects").getParent(); Files.createDirectories(assets); + if (metadata.has("assetIndex")) downloadAssets(metadata.getAsJsonObject("assetIndex"), assets, directory); + Path logging = null; + if (metadata.has("logging") && metadata.getAsJsonObject("logging").has("client")) { + JsonObject file = metadata.getAsJsonObject("logging").getAsJsonObject("client").getAsJsonObject("file"); + logging = downloads.download(file.get("url").getAsString(), "assets/log_configs/" + file.get("id").getAsString(), + file.get("sha1").getAsString(), "SHA-1", file.get("size").getAsLong()); + } + return new LaunchPlan(metadata, gameVersion, directory, natives, assets, downloads.resolve("libraries/.marker").getParent(), client, classpath, account, platform, logging); + } + Path client(JsonObject metadata) throws IOException { + if (!metadata.has("downloads") || !metadata.getAsJsonObject("downloads").has("client")) throw new IOException("Missing Minecraft client download metadata"); + JsonObject client = metadata.getAsJsonObject("downloads").getAsJsonObject("client"); + String id = metadata.has("jar") ? metadata.get("jar").getAsString() : metadata.get("id").getAsString(); validateVersion(id); + Path original = downloads.download(client.get("url").getAsString(), "versions/" + id + "/" + id + ".jar", client.get("sha1").getAsString(), "SHA-1", client.get("size").getAsLong()); + String launchedVersion = metadata.get("id").getAsString(); validateVersion(launchedVersion); + if (launchedVersion.equals(id)) return original; + // Forge/NeoForge's module ignore list uses ${version_name}.jar. Keep the inherited client under that name. + Path alias = downloads.resolve("versions/" + launchedVersion + "/" + launchedVersion + ".jar"); + linkOrCopy(original, alias); return alias; + } + Path artifact(JsonObject artifact) throws IOException { + return downloads.download(artifact.get("url").getAsString(), "libraries/" + artifact.get("path").getAsString(), + string(artifact, "sha1", null), "SHA-1", artifact.has("size") ? artifact.get("size").getAsLong() : -1); + } + Path library(JsonObject library) throws IOException { + if (library.has("downloads")) { + JsonObject information = library.getAsJsonObject("downloads"); + return information.has("artifact") ? artifact(information.getAsJsonObject("artifact")) : null; + } + String path = mavenPath(library.get("name").getAsString()); + String repository = string(library, "url", "https://libraries.minecraft.net/"); + if (repository.startsWith("http://")) repository = "https://" + repository.substring(7); + String url = repository + (repository.endsWith("/") ? "" : "/") + path; + String hash = string(library, "sha1", null); + if (hash == null) hash = checksum(downloads.text(url + ".sha1", 4096)); + return downloads.download(url, "libraries/" + path, hash, "SHA-1", library.has("size") ? library.get("size").getAsLong() : -1); + } + void downloadAssets(JsonObject information, Path assets, Path gameDirectory) throws Exception { + String id = information.get("id").getAsString(); validateVersion(id); + Path indexFile = downloads.download(information.get("url").getAsString(), "assets/indexes/" + id + ".json", information.get("sha1").getAsString(), "SHA-1", information.get("size").getAsLong()); + JsonObject index = readJson(indexFile); boolean virtual = index.has("virtual") && index.get("virtual").getAsBoolean(); + boolean resources = index.has("map_to_resources") && index.get("map_to_resources").getAsBoolean(); + ExecutorService pool = Executors.newFixedThreadPool(4); List> futures = new ArrayList<>(); + try { + for (Map.Entry entry : index.getAsJsonObject("objects").entrySet()) { + final String name = entry.getKey(); final JsonObject object = entry.getValue().getAsJsonObject(); + futures.add(pool.submit(() -> { + String hash = object.get("hash").getAsString(); + if (!hash.matches("[a-f0-9]{40}")) throw new IOException("Invalid asset hash"); + Path source = downloads.download("https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash, + "assets/objects/" + hash.substring(0, 2) + "/" + hash, hash, "SHA-1", object.get("size").getAsLong()); + if (virtual) linkOrCopy(source, LauncherDownloads.safeResolve(assets.resolve("virtual/" + id), name)); + if (resources) linkOrCopy(source, LauncherDownloads.safeResolve(gameDirectory.resolve("resources"), name)); + return null; + })); + } + for (Future future : futures) future.get(); + } catch (ExecutionException e) { throw new IOException("Asset preparation failed", e.getCause()); } + finally { pool.shutdownNow(); } + } + static void linkOrCopy(Path source, Path target) throws IOException { + Files.createDirectories(target.getParent()); LauncherDownloads.rejectLinks(target); + if (Files.isRegularFile(target) && Files.size(target) == Files.size(source) + && LauncherDownloads.hash(target, "SHA-1").equals(LauncherDownloads.hash(source, "SHA-1"))) return; + Files.deleteIfExists(target); + try { Files.createLink(target, source); } catch (IOException | UnsupportedOperationException e) { Files.copy(source, target); } + } + public static String mavenPath(String coordinate) throws IOException { + String[] extension = coordinate.split("@", -1); if (extension.length > 2) throw new IOException("Invalid Maven coordinate"); + String[] pieces = extension[0].split(":", -1); + if (pieces.length < 3 || pieces.length > 4) throw new IOException("Invalid Maven coordinate"); + for (String piece : pieces) if (!piece.matches("[A-Za-z0-9_+.-]+") || piece.equals(".") || piece.equals("..")) throw new IOException("Unsafe Maven coordinate"); + String type = extension.length == 2 ? extension[1] : "jar"; + if (!type.matches("[A-Za-z0-9]+")) throw new IOException("Unsafe Maven extension"); + return pieces[0].replace('.', '/') + "/" + pieces[1] + "/" + pieces[2] + "/" + pieces[1] + "-" + pieces[2] + + (pieces.length == 4 ? "-" + pieces[3] : "") + "." + type; + } + static String checksum(String text) throws IOException { + String value = text.trim().split("\\s+")[0]; if (!value.matches("[a-fA-F0-9]{40}")) throw new IOException("Invalid repository SHA-1 checksum"); return value; + } + static void validateVersion(String value) throws IOException { if (value == null || !value.matches("[A-Za-z0-9_+.-]{1,160}") || value.equals(".") || value.equals("..")) throw new IOException("Unsafe version identifier"); } + static JsonObject readJson(Path path) throws IOException { try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { return JsonParser.parseReader(reader).getAsJsonObject(); } } + static String string(JsonObject object, String name, String fallback) { return object.has(name) && !object.get(name).isJsonNull() ? object.get(name).getAsString() : fallback; } + public static JsonObject merge(JsonObject parent, JsonObject child) { + JsonObject merged = parent.deepCopy(); + for (Map.Entry entry : child.entrySet()) if (!entry.getKey().equals("libraries") && !entry.getKey().equals("arguments") && !entry.getKey().equals("inheritsFrom")) merged.add(entry.getKey(), entry.getValue().deepCopy()); + if (!child.has("jar")) merged.addProperty("jar", string(parent, "jar", parent.get("id").getAsString())); + LinkedHashMap libraries = new LinkedHashMap<>(); + for (JsonObject source : Arrays.asList(parent, child)) if (source.has("libraries")) for (JsonElement library : source.getAsJsonArray("libraries")) { + String name = library.getAsJsonObject().get("name").getAsString(); String[] parts = name.split(":"); + String key = parts[0] + ":" + parts[1] + (parts.length > 3 ? ":" + parts[3] : ""); libraries.put(key, library.deepCopy()); + } + JsonArray libraryArray = new JsonArray(); for (JsonElement library : libraries.values()) libraryArray.add(library); merged.add("libraries", libraryArray); + if (parent.has("arguments") || child.has("arguments")) { + JsonObject arguments = new JsonObject(); + for (String kind : Arrays.asList("jvm", "game")) { + JsonArray values = new JsonArray(); + for (JsonObject source : Arrays.asList(parent, child)) if (source.has("arguments") && source.getAsJsonObject("arguments").has(kind)) + for (JsonElement argument : source.getAsJsonObject("arguments").getAsJsonArray(kind)) values.add(argument.deepCopy()); + arguments.add(kind, values); + } + merged.add("arguments", arguments); + } + return merged; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/PROTOCOL.md b/src/main/java/com/osiris/autoplug/client/launcher/PROTOCOL.md new file mode 100644 index 00000000..0f6991b7 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/PROTOCOL.md @@ -0,0 +1,42 @@ +# Native launch protocols + +This package is original MIT code written against the official metadata and public service protocols. No launcher project code or binaries are linked into AutoPlug. Official loader installer JARs are downloaded into the user cache only when the user prepares that loader. They are not bundled in AutoPlug. + +## Interface + +Use NativeLauncher.prepare(gameVersion, loader, gameDirectory, LaunchAccount) to install a version and receive a LaunchPlan. An overload accepts a pinned loaderVersion. Configure a JavaResolver through the constructor when application settings contain a Java path override: installer processors need the same resolver as the final game process. LaunchPlan.buildCommand returns a ProcessBuilder argument vector; it can contain an in-memory access token and must never be logged or persisted. LaunchPlan.launch inherits process output and uses the isolated profile directory. + +MicrosoftOAuth requires the application's own registered Microsoft public-client ID. Device authorization begins only after an explicit account-owner sign-in action. The device code, access token and refresh token remain in memory. Minecraft authentication exchanges the Microsoft access token for Xbox Live and XSTS tokens, checks Java Edition entitlements and reads the Minecraft profile. A registered app may also need Minecraft Services approval; no shared third-party application ID is supplied. Final authenticated game joining is a maintainer validation step. + +## Direct connection + +Mojang removed the server and port launch arguments in snapshot 23w14a, shipped as Minecraft 1.20. The engine uses quickPlayMultiplayer for metadata that advertises that option and Minecraft 1.20 or newer; older clients receive server and port arguments. The original specification's intent (direct joining) is preserved using the applicable protocol. Java 21 begins in Mojang metadata for 1.20.5, which is separate from the direct-connection change. + +Primary source: https://www.minecraft.net/en-us/article/minecraft-snapshot-23w14a (Technical Changes and Quick Play). +Java 21 release: https://www.minecraft.net/en-us/article/minecraft-java-edition-1-20-5 . + +The public Mojang manifest read on 2026-10-03 reported release 26.3, whose metadata requires Java 25. Runtime selection follows metadata rather than limiting current releases to the original 8/17/21 examples. NeoForge's year-based versions include the Minecraft hotfix component: 26.3 selects 26.3.0.*, while 26.3.1 selects 26.3.1.*. Primary NeoForge announcement: https://neoforged.net/news/26.1release/ . + +## Download and loader processing + +Mojang SHA-1 values verify the client, assets, logging configuration and library artifacts. Loader library metadata hashes or repository SHA-1 sidecars verify Maven downloads. Adoptium archives use their published SHA-256 values, with isolated per-major runtime directories and atomic installation staging. Archive and metadata paths cannot leave the selected cache/profile roots. Native extraction respects Mojang OS/architecture/feature rules. Legacy natives classifiers are selected separately; modern natives remain on the classpath for LWJGL resource loading. + +Fabric and Quilt JSON profiles merge with the inherited Mojang metadata. Forge and NeoForge install_profile.json supplies client-side processors, embedded Maven libraries, data expressions and required output checksums. Only client-side processors run. Successful generated files receive a local SHA-256 installation receipt so a later preparation can reuse them; corruption invalidates that receipt. Inherited client JAR aliases use the loader version name so NeoForge's module ignore list applies correctly. No processor is given any account credentials. + +Installer ROOT denotes the shared cache installation root; processors run from a separate work directory. This makes NeoForge's declared embedded-library extraction directory agree with the libraryDirectory launch property. The official NeoForge 26.3 metadata supplies its loader startup class, version arguments and libraryDirectory property without declaring the patched Minecraft JAR as a classpath library. The engine keeps that official launch metadata intact; successful real loader startup remains part of maintainer validation. + +Official endpoints: +- https://piston-meta.mojang.com/mc/game/version_manifest_v2.json +- https://meta.fabricmc.net/ +- https://meta.quiltmc.org/ +- https://maven.minecraftforge.net/ +- https://maven.neoforged.net/releases/ +- https://api.adoptium.net/q/swagger-ui +- https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code +- https://learn.microsoft.com/en-us/gaming/gdk/docs/services/fundamentals/s2s-auth-calls/service-authentication/live-website-authentication + +## Verification boundary + +The tests include frozen official Mojang metadata (1.12.2, 1.19.4, 1.20.4, 1.20.5 and 26.3), Fabric/Quilt profiles and official Forge/NeoForge installer manifests for both 1.20.4 and 26.3. Their provenance is in src/test/resources/native-launcher/sources.json. Additional isolated fixtures exercise network failures, checksum failures, asset/native extraction, OS rules, processor subprocesses, Java version probes and an actual ProcessBuilder launch of an original tiny offline test JAR. These fixtures are labelled as fixtures: they are not evidence of a live Minecraft game, a Microsoft account login or server joining. + +Public official metadata and checksum endpoints were also read during development. No paid account, owner credentials, client connection to a public game server or real game launch was used. Maintainers perform the final authenticated game join according to the issue's acceptance plan. diff --git a/src/main/java/com/osiris/autoplug/client/profiles/AutoPlugProfileResolver.java b/src/main/java/com/osiris/autoplug/client/profiles/AutoPlugProfileResolver.java new file mode 100644 index 00000000..2d34a322 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/AutoPlugProfileResolver.java @@ -0,0 +1,130 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.*; +import com.osiris.autoplug.client.tasks.updater.mods.ModrinthAPI; +import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; +import com.osiris.autoplug.client.tasks.updater.plugins.InstalledPluginLoader; +import com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import com.osiris.autoplug.client.utils.UtilsMinecraft; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; + +/** Exact-file Modrinth lookup, plus explicitly configured Spigot/Bukkit identities using existing AutoPlug searches. */ +public final class AutoPlugProfileResolver implements ProfileUpdater.Resolver { + private final ModrinthAPI api; + private final ResourceFinder finder; + public AutoPlugProfileResolver() { this(new ModrinthAPI(), new ResourceFinder()); } + public AutoPlugProfileResolver(ModrinthAPI api, ResourceFinder finder) { this.api = api; this.finder = finder; } + @Override public ProfileUpdater.Resolution resolve(Profile profile, Path installed) throws Exception { + Properties sources = new Properties(); + Path sourceFile = profile.directoryPath.resolve("asset-sources.properties"); + if (Files.exists(sourceFile)) try (InputStream input = Files.newInputStream(sourceFile)) { sources.load(input); } + String filename = installed.getFileName().toString().replaceFirst("\\.disabled$", ""); + String project = sources.getProperty(filename + ".modrinth"); + if (project == null) { + JsonObject exact = api.identifyProfileFile(installed); + if (exact != null) project = exact.get("project_id").getAsString(); + } + if (project != null) return fromVersion(project, finder.findProfileRelease(api, project, loaders(profile), profile.gameVersion, null)); + String curseforge = sources.getProperty(filename + ".curseforge"); + if (curseforge != null && profile.type != Profile.Type.PLUGINS) return fromCurseForge(curseforge, finder.findProfileCurseForgeRelease(curseforge, profile.loader, profile.gameVersion)); + if (profile.type == Profile.Type.PLUGINS) { + String spigot = sources.getProperty(filename + ".spigot"), bukkit = sources.getProperty(filename + ".bukkit"); + if (spigot != null || bukkit != null) return plugin(profile, installed, spigot, bukkit); + } + // Unknown identity is reported for explicit review, never mistaken for a timeout or malformed API response. + return ProfileUpdater.Resolution.unsupported("unknown:" + SharedArtifactCache.hash(installed, "SHA-1")); + } + @Override public ProfileUpdater.Resolution dependency(Profile profile, ProfileUpdater.Dependency dependency) throws Exception { + String project = dependency.projectId; + if (project != null && project.startsWith("curseforge:")) { + String id = project.substring("curseforge:".length()); + return fromCurseForge(id, finder.findProfileCurseForgeRelease(id, profile.loader, profile.gameVersion)); + } + if (project == null && dependency.versionId != null) { + JsonObject pinned = api.profileVersion(dependency.versionId); + if (pinned != null) project = pinned.get("project_id").getAsString(); + } + if (project == null) throw new IOException("Required dependency has no resolvable project identity"); + return fromVersion(project, finder.findProfileRelease(api, project, loaders(profile), profile.gameVersion, dependency.versionId)); + } + private ProfileUpdater.Resolution plugin(Profile profile, Path installed, String spigot, String bukkit) throws Exception { + MinecraftPlugin plugin = new MinecraftPlugin(installed.toString(), installed.getFileName().toString(), "0", "", parseId(spigot), parseId(bukkit), null); + SearchResult found; + String identity; + if (spigot != null) { + JsonObject resource = getJson("https://api.spiget.org/v2/resources/" + parseId(spigot)); + boolean compatible = false; + JsonArray tested = resource.getAsJsonArray("testedVersions"); + if (tested != null) for (JsonElement version : tested) if (version.getAsString().equals(profile.gameVersion) || profile.gameVersion.startsWith(version.getAsString() + ".")) compatible = true; + if (!compatible) return ProfileUpdater.Resolution.unsupported("spigot:" + spigot); + found = finder.findPluginBySpigotId(plugin); identity = "spigot:" + spigot; + } else { + found = finder.findProfilePluginByBukkitId(plugin, profile.gameVersion); identity = "bukkit:" + bukkit; + } + if (found == null || found.isError()) throw new IOException("Plugin lookup failed", found == null ? null : found.getException()); + if (found.isPremium) throw new IOException("Premium plugin requires a separately authorized download"); + if (found.type == SearchResult.Type.RESOURCE_NOT_FOUND) return ProfileUpdater.Resolution.unsupported(identity); + String name = found.fileName == null ? installed.getFileName().toString().replaceFirst("\\.disabled$", "") : found.fileName; + return new ProfileUpdater.Resolution(true, identity, found.latestVersion, found.latestVersion, name, found.downloadUrl, null, null, found.expectedMD5, Collections.emptyList()); + } + private static int parseId(String value) throws IOException { if (value == null) return 0; try { return Integer.parseInt(value); } catch (NumberFormatException bad) { throw new IOException("Invalid plugin project id", bad); } } + private static List loaders(Profile profile) { + if (profile.type == Profile.Type.PLUGINS) return InstalledPluginLoader.forProfile(profile.loader); + return Collections.singletonList(profile.loader.toLowerCase(Locale.ROOT)); + } + private static ProfileUpdater.Resolution fromVersion(String project, JsonObject version) throws IOException { + if (version == null) return ProfileUpdater.Resolution.unsupported(project); + JsonObject selected = null; + for (JsonElement element : version.getAsJsonArray("files")) { + JsonObject file = element.getAsJsonObject(); + if (!file.get("filename").getAsString().endsWith(".jar")) continue; + if (selected == null || file.has("primary") && file.get("primary").getAsBoolean()) selected = file; + } + if (selected == null) throw new IOException("Compatible release has no jar artifact"); + JsonObject hashes = selected.getAsJsonObject("hashes"); + List dependencies = new ArrayList<>(); + List incompatible = new ArrayList<>(); + if (version.has("dependencies")) for (JsonElement element : version.getAsJsonArray("dependencies")) { + JsonObject dependency = element.getAsJsonObject(); + if ("required".equals(dependency.get("dependency_type").getAsString())) dependencies.add(new ProfileUpdater.Dependency(string(dependency, "project_id"), string(dependency, "version_id"))); + if ("incompatible".equals(dependency.get("dependency_type").getAsString())) { + String incompatibleProject = string(dependency, "project_id"); + if (incompatibleProject == null) throw new IOException("Incompatible dependency is missing its project identity"); + incompatible.add(incompatibleProject); + } + } + return new ProfileUpdater.Resolution(true, version.get("project_id").getAsString(), version.get("id").getAsString(), version.get("version_number").getAsString(), selected.get("filename").getAsString(), selected.get("url").getAsString(), string(hashes, "sha1"), string(hashes, "sha512"), null, dependencies, incompatible); + } + private static ProfileUpdater.Resolution fromCurseForge(String project, JsonObject file) throws IOException { + if (file == null) return ProfileUpdater.Resolution.unsupported("curseforge:" + project); + String download = string(file, "downloadUrl"); + if (download == null) throw new IOException("CurseForge author has not exposed an authorized download URL"); + String sha1 = null, md5 = null; + for (JsonElement value : file.getAsJsonArray("hashes")) { + JsonObject hash = value.getAsJsonObject(); int type = hash.get("algo").getAsInt(); + if (type == 1) sha1 = hash.get("value").getAsString(); if (type == 2) md5 = hash.get("value").getAsString(); + } + List dependencies = new ArrayList<>(); List incompatible = new ArrayList<>(); + if (file.has("dependencies")) for (JsonElement value : file.getAsJsonArray("dependencies")) { + JsonObject dependency = value.getAsJsonObject(); String id = "curseforge:" + dependency.get("modId").getAsString(); int type = dependency.get("relationType").getAsInt(); + if (type == 3) dependencies.add(new ProfileUpdater.Dependency(id, null)); if (type == 5) incompatible.add(id); + } + return new ProfileUpdater.Resolution(true, "curseforge:" + project, file.get("id").getAsString(), file.get("displayName").getAsString(), file.get("fileName").getAsString(), download, sha1, null, md5, dependencies, incompatible); + } + private static String string(JsonObject object, String key) { return object != null && object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : null; } + private static JsonObject getJson(String url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(30000); connection.setRequestProperty("User-Agent", "AutoPlug-Client/10.2.0"); + try { + int status = connection.getResponseCode(); if (status != 200) throw new IOException("Plugin API returned HTTP " + status); + try (Reader input = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { return JsonParser.parseReader(input).getAsJsonObject(); } + catch (RuntimeException malformed) { throw new IOException("Invalid plugin API response", malformed); } + } finally { connection.disconnect(); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/Profile.java b/src/main/java/com/osiris/autoplug/client/profiles/Profile.java new file mode 100644 index 00000000..94b55aae --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/Profile.java @@ -0,0 +1,37 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; + +/** An immutable profile; it never changes server-wide updater configuration. */ +public final class Profile { + public enum Type { MODS, PLUGINS, MODS_SERVER } + public final String id; + public final Type type; + public final String gameVersion; + public final String loader; + public final Path directoryPath; + public final boolean isTemplate; + + public Profile(String id, Type type, String gameVersion, String loader, Path directoryPath, boolean isTemplate) { + if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}")) throw new IllegalArgumentException("Invalid profile id"); + if (gameVersion == null || !gameVersion.matches("[a-zA-Z0-9][a-zA-Z0-9_.+ -]{0,79}")) throw new IllegalArgumentException("Invalid game version"); + if (loader == null || !loader.matches("[a-zA-Z0-9_-]{1,40}")) throw new IllegalArgumentException("Invalid loader"); + this.id = id; + this.type = Objects.requireNonNull(type, "type"); + this.gameVersion = gameVersion; + this.loader = loader.toUpperCase(Locale.ROOT); + this.directoryPath = Objects.requireNonNull(directoryPath, "directoryPath").toAbsolutePath().normalize(); + this.isTemplate = isTemplate; + } + public String getId() { return id; } + public Type getType() { return type; } + public String getGameVersion() { return gameVersion; } + public String getLoader() { return loader; } + public Path getDirectoryPath() { return directoryPath; } + public boolean isTemplate() { return isTemplate; } + public Path assetsDirectory() { return directoryPath.resolve(type == Type.PLUGINS ? "plugins" : "mods"); } + @Override public String toString() { return id + " [" + type + ", " + gameVersion + ", " + loader + (isTemplate ? ", template" : "") + "]"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileCommands.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileCommands.java new file mode 100644 index 00000000..18156d40 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileCommands.java @@ -0,0 +1,88 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.tasks.updater.mods.TaskModsUpdater; +import com.osiris.autoplug.client.tasks.updater.plugins.TaskPluginsUpdater; +import java.io.IOException; +import java.util.*; +import java.util.function.Consumer; + +/** Extends existing .check/.update commands with an explicit profile; unrelated legacy commands pass through. */ +public final class ProfileCommands { + private final ProfileStore store; + private final ProfileUpdater updater; + public ProfileCommands(ProfileStore store, ProfileUpdater updater) { this.store = store; this.updater = updater; } + public static boolean tryHandle(String command, Consumer output) { + if (!isProfileCommand(command)) return false; + try { + ProfileStore store = ProfileStore.defaultStore(); + return new ProfileCommands(store, new ProfileUpdater(store.getCache())).handle(command, output); + } catch (Exception exception) { output.accept("Profile command failed: " + exception.getMessage()); return true; } + } + private static boolean isProfileCommand(String command) { + String text = command == null ? "" : command.trim(); + return text.equals(".profiles") || text.startsWith(".profiles ") || text.matches("\\.(check|update) (mods|plugins|mods-server|mods_server)(?: .*|$)"); + } + public boolean handle(String command, Consumer output) { + if (!isProfileCommand(command)) return false; + try { + List words = new ArrayList<>(Arrays.asList(command.trim().split("\\s+"))); + boolean confirm = words.remove("--confirm-disabling"); + if (words.get(0).equals(".profiles")) profiles(words, confirm, output); + else { + if (words.size() != 2 && !(words.size() == 4 && words.get(2).equals("--profile"))) throw new IOException("Usage: .check/.update mods|plugins|mods-server [--profile ] [--confirm-disabling]"); + Profile profile = words.size() == 4 ? store.get(words.get(3)) : store.getActive(type(words.get(1))); + if (profile == null) { + if (words.get(0).equals(".check")) return false; // Preserve current-directory server checks in the interactive console. + throw new IOException("Select a default " + words.get(1) + " profile first: .profiles activate , or use --profile "); + } + Profile.Type requested = type(words.get(1)); + if (profile.type != requested && !(requested == Profile.Type.MODS && profile.type == Profile.Type.MODS_SERVER)) throw new IOException("Requested updater type does not match profile type"); + process(profile, words.get(0).equals(".update"), confirm, output); + } + } catch (Exception exception) { output.accept("Profile command failed: " + exception.getMessage()); } + return true; + } + private void profiles(List words, boolean confirm, Consumer output) throws IOException { + if (words.size() == 1 || words.get(1).equals("help")) { usage(output); return; } + String action = words.get(1); + if (action.equals("list")) { + Profile.Type filter = null; + if (words.size() == 4 && words.get(2).equals("--type")) filter = type(words.get(3)); + else if (words.size() != 2) throw new IOException("Usage: .profiles list [--type mods|plugins|mods-server]"); + for (Profile profile : store.list()) if (filter == null || filter == profile.type) output.accept(profile.toString()); + } else if (action.equals("create")) { + boolean template = words.remove("--template"); Profile.Type type = Profile.Type.MODS; + int flag = words.indexOf("--type"); + if (flag != -1) { if (flag + 1 >= words.size()) throw new IOException("Missing profile type"); type = type(words.remove(flag + 1)); words.remove(flag); } + if (words.size() != 5) throw new IOException("Usage: .profiles create [--type mods|plugins|mods-server] [--template]"); + output.accept("Created " + store.create(words.get(2), type, words.get(3), words.get(4), template)); + } else if (action.equals("clone")) { + if (words.size() != 4 && words.size() != 5) throw new IOException("Usage: .profiles clone [new-id] [--confirm-disabling]"); + Profile cloned = words.size() == 5 ? store.clone(words.get(2), words.get(4), words.get(3)) : store.clone(words.get(2), words.get(3)); + try { + ProfileUpdater.Plan plan = process(cloned, false, false, output); + updater.apply(plan, confirm); output.accept("Cloned and migrated " + cloned); + } catch (IOException failure) { + try { store.delete(cloned.id); } catch (IOException cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } + } else if (action.equals("delete")) { + if (words.size() != 3) throw new IOException("Usage: .profiles delete "); store.delete(words.get(2)); output.accept("Deleted profile " + words.get(2)); + } else if (action.equals("template")) { + if (words.size() != 4 || !words.get(3).matches("true|false")) throw new IOException("Usage: .profiles template true|false"); + output.accept("Updated " + store.setTemplate(words.get(2), Boolean.parseBoolean(words.get(3)))); + } else if (action.equals("activate")) { + if (words.size() != 3) throw new IOException("Usage: .profiles activate "); output.accept("Default update profile: " + store.setActive(words.get(2))); + } else throw new IOException("Unknown profile action: " + action); + } + private ProfileUpdater.Plan process(Profile profile, boolean apply, boolean confirm, Consumer output) throws IOException { + return profile.type != Profile.Type.PLUGINS ? TaskModsUpdater.processProfile(profile, updater, apply, confirm, output) : TaskPluginsUpdater.processProfile(profile, updater, apply, confirm, output); + } + private static Profile.Type type(String value) throws IOException { + try { return Profile.Type.valueOf(value.toUpperCase(Locale.ROOT).replace('-', '_')); } catch (IllegalArgumentException invalid) { throw new IOException("Type must be mods, plugins or mods-server"); } + } + private static void usage(Consumer output) { + output.accept(".profiles list [--type mods|plugins|mods-server]\n.profiles create [--type mods|plugins|mods-server] [--template]\n.profiles clone [new-id] [--confirm-disabling]\n.profiles delete \n.profiles template true|false\n.profiles activate \n.check/.update mods|plugins|mods-server [--profile ] [--confirm-disabling]"); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileFiles.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileFiles.java new file mode 100644 index 00000000..cc7cff83 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileFiles.java @@ -0,0 +1,33 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; + +final class ProfileFiles { + private ProfileFiles() {} + static Path contained(Path root, Path value) throws IOException { + root = root.toAbsolutePath().normalize(); + value = value.toAbsolutePath().normalize(); + if (!value.startsWith(root) || value.equals(root)) throw new IOException("Path is outside the managed profile directory: " + value); + for (Path at = value; at != null && at.startsWith(root); at = at.getParent()) { + if (Files.isSymbolicLink(at)) throw new IOException("Symbolic profile paths are not supported: " + at); + if (at.equals(root)) break; + } + if (Files.exists(root) && !value.getParent().toRealPath().startsWith(root.toRealPath())) throw new IOException("Profile path escapes its root"); + return value; + } + static void move(Path from, Path to) throws IOException { + try { Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); } + } + static void deleteTree(Path root, Path target) throws IOException { + target = contained(root, target); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(target, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } + @Override public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { if (failure != null) throw failure; Files.delete(directory); return FileVisitResult.CONTINUE; } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java new file mode 100644 index 00000000..0966807f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java @@ -0,0 +1,141 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; + +/** Managed profiles live below root/instances; no process-wide user.dir or config mutation. */ +public final class ProfileStore { + /** UI and CLI may hold separate stores for the same root; defaults are one shared transaction. */ + private static final ConcurrentMap ACTIVE_LOCKS = new ConcurrentHashMap<>(); + private final Path root, metadata, instances; + private final SharedArtifactCache cache; + private final Object activeLock; + public ProfileStore(Path root) throws IOException { + this.root = root.toAbsolutePath().normalize(); + activeLock = ACTIVE_LOCKS.computeIfAbsent(this.root, path -> new Object()); + metadata = this.root.resolve("profiles"); instances = this.root.resolve("instances"); + Files.createDirectories(metadata); Files.createDirectories(instances); + if (Files.isSymbolicLink(this.root) || Files.isSymbolicLink(metadata) || Files.isSymbolicLink(instances)) throw new IOException("Managed profile roots cannot be links"); + cache = new SharedArtifactCache(this.root.resolve("cache")); + } + public static ProfileStore defaultStore() throws IOException { return new ProfileStore(Paths.get(System.getProperty("user.home"), ".autoplug")); } + public Path getRoot() { return root; } + public SharedArtifactCache getCache() { return cache; } + public synchronized Profile create(String id, Profile.Type type, String version, String loader, boolean template) throws IOException { + Profile profile = new Profile(id, type, version, loader, instances.resolve(id), template); + if (Files.exists(metadata.resolve(id + ".properties")) || Files.exists(profile.directoryPath)) throw new IOException("Profile already exists: " + id); + ProfileFiles.contained(instances, profile.directoryPath); + Files.createDirectory(profile.directoryPath); + Files.createDirectories(profile.assetsDirectory()); + try { save(profile); } catch (IOException exception) { ProfileFiles.deleteTree(instances, profile.directoryPath); throw exception; } + return profile; + } + public synchronized Profile get(String id) throws IOException { + validateId(id); + Path file = ProfileFiles.contained(metadata, metadata.resolve(id + ".properties")); + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(file)) { values.load(input); } + catch (IllegalArgumentException malformed) { throw new IOException("Invalid profile metadata encoding: " + id, malformed); } + try { + for (String key : Arrays.asList("id", "type", "gameVersion", "loader", "isTemplate")) if (values.getProperty(key) == null) throw new IllegalArgumentException("Missing " + key); + if (!id.equals(values.getProperty("id")) || !values.getProperty("isTemplate").matches("true|false")) throw new IllegalArgumentException("Inconsistent profile id or template value"); + Profile profile = new Profile(id, Profile.Type.valueOf(values.getProperty("type")), values.getProperty("gameVersion"), values.getProperty("loader"), instances.resolve(id), Boolean.parseBoolean(values.getProperty("isTemplate"))); + ProfileFiles.contained(instances, profile.directoryPath); + return profile; + } catch (RuntimeException exception) { throw new IOException("Invalid profile metadata: " + id, exception); } + } + public synchronized List list() throws IOException { + List profiles = new ArrayList<>(); + try (Stream files = Files.list(metadata)) { + for (Path file : (Iterable) files.filter(p -> p.getFileName().toString().endsWith(".properties"))::iterator) { + String name = file.getFileName().toString(); profiles.add(get(name.substring(0, name.length() - 11))); + } + } + profiles.sort(Comparator.comparing(p -> p.id)); return Collections.unmodifiableList(profiles); + } + public synchronized Profile clone(String sourceId, String targetVersion) throws IOException { + String base = sourceId + "-" + targetVersion.replaceAll("[^a-zA-Z0-9_-]", "-"); + if (base.length() > 70) base = base.substring(0, 70); + String id = base; int count = 2; + while (Files.exists(metadata.resolve(id + ".properties")) || Files.exists(instances.resolve(id))) id = base + "-" + count++; + return clone(sourceId, id, targetVersion); + } + public synchronized Profile clone(String sourceId, String newId, String targetVersion) throws IOException { + Profile source = get(sourceId); + Profile target = create(newId, source.type, targetVersion, source.loader, false); + try { + Files.walkFileTree(source.directoryPath, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Cannot clone linked directory"); + Path relative = source.directoryPath.relativize(directory); + if (relative.toString().equals(".autoplug-transactions")) return FileVisitResult.SKIP_SUBTREE; + Files.createDirectories(target.directoryPath.resolve(relative)); return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (!attrs.isRegularFile() || Files.isSymbolicLink(file)) throw new IOException("Cannot clone linked/special file: " + file); + String filename = file.getFileName().toString(); + if (filename.equals("credentials.json") || filename.equals("launcher_accounts.json") || filename.endsWith(".lock")) return FileVisitResult.CONTINUE; + Path destination = target.directoryPath.resolve(source.directoryPath.relativize(file)); + if (filename.endsWith(".jar") || filename.endsWith(".jar.disabled")) cache.link(cache.store(file), destination); + else Files.copy(file, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + return FileVisitResult.CONTINUE; + } + }); + return target; + } catch (IOException failure) { delete(newId); throw failure; } + } + public synchronized void delete(String id) throws IOException { + synchronized (activeLock) { + Profile profile = get(id); + Properties active = readActive(); + if (id.equals(active.getProperty(profile.type.name()))) { active.remove(profile.type.name()); writeActive(active); } + ProfileFiles.deleteTree(instances, profile.directoryPath); + Files.delete(metadata.resolve(id + ".properties")); + } + } + public synchronized Profile setTemplate(String id, boolean template) throws IOException { + Profile old = get(id); + Profile updated = new Profile(old.id, old.type, old.gameVersion, old.loader, old.directoryPath, template); + save(updated); return updated; + } + public synchronized Profile setActive(String id) throws IOException { + synchronized (activeLock) { + Profile profile = get(id); Properties active = readActive(); active.setProperty(profile.type.name(), id); writeActive(active); return profile; + } + } + public synchronized Profile getActive(Profile.Type type) throws IOException { + synchronized (activeLock) { + String id = readActive().getProperty(type.name()); + if (id == null) return null; + try { Profile profile = get(id); if (profile.type != type) throw new IOException("Active profile type does not match"); return profile; } + catch (RuntimeException malformed) { throw new IOException("Invalid active profile id", malformed); } + } + } + private Properties readActive() throws IOException { + Path file = ProfileFiles.contained(root, root.resolve("active-profiles.properties")); Properties values = new Properties(); + if (Files.exists(file)) { if (Files.size(file) > 8192) throw new IOException("Invalid active profile settings"); try (InputStream input = Files.newInputStream(file)) { values.load(input); } catch (IllegalArgumentException malformed) { throw new IOException("Invalid active profile settings", malformed); } } + return values; + } + private void writeActive(Properties values) throws IOException { + Path file = ProfileFiles.contained(root, root.resolve("active-profiles.properties")); Path temporary = Files.createTempFile(root, "active-", ".part"); + try { try (OutputStream output = Files.newOutputStream(temporary)) { values.store(output, "Default MODS and PLUGINS update profiles"); } ProfileFiles.move(temporary, file); } + finally { Files.deleteIfExists(temporary); } + } + private void save(Profile profile) throws IOException { + Properties values = new Properties(); + values.setProperty("id", profile.id); values.setProperty("type", profile.type.name()); values.setProperty("gameVersion", profile.gameVersion); + values.setProperty("loader", profile.loader); values.setProperty("isTemplate", String.valueOf(profile.isTemplate)); + Path temporary = Files.createTempFile(metadata, profile.id + "-", ".part"); + try { + try (OutputStream output = Files.newOutputStream(temporary)) { values.store(output, "AutoPlug unified profile"); } + ProfileFiles.move(temporary, metadata.resolve(profile.id + ".properties")); + } finally { Files.deleteIfExists(temporary); } + } + private static void validateId(String id) { if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}")) throw new IllegalArgumentException("Invalid profile id"); } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdater.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdater.java new file mode 100644 index 00000000..d9f51c59 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdater.java @@ -0,0 +1,292 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import java.io.*; +import java.net.*; +import java.nio.channels.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.ZipFile; + +/** A reviewable, profile-scoped adapter around AutoPlug's existing resource/updater services. */ +public final class ProfileUpdater { + public interface Resolver { + Resolution resolve(Profile profile, Path installed) throws Exception; + default Resolution dependency(Profile profile, Dependency dependency) throws Exception { throw new IOException("Unresolved required dependency: " + dependency.projectId); } + } + public interface Downloader { void download(String url, Path destination) throws IOException; } + public static final class Dependency { + public final String projectId, versionId; + public Dependency(String projectId, String versionId) { this.projectId = projectId; this.versionId = versionId; } + } + public static final class Resolution { + public final boolean compatible; + public final String projectId, versionId, version, fileName, url, sha1, sha512, md5; + public final List dependencies; + public final List incompatibleProjects; + public Resolution(boolean compatible, String projectId, String versionId, String version, String fileName, String url, String sha1, String sha512, List dependencies) { + this(compatible, projectId, versionId, version, fileName, url, sha1, sha512, null, dependencies); + } + public Resolution(boolean compatible, String projectId, String versionId, String version, String fileName, String url, String sha1, String sha512, String md5, List dependencies) { + this(compatible, projectId, versionId, version, fileName, url, sha1, sha512, md5, dependencies, Collections.emptyList()); + } + public Resolution(boolean compatible, String projectId, String versionId, String version, String fileName, String url, String sha1, String sha512, String md5, List dependencies, List incompatibleProjects) { + this.compatible = compatible; this.projectId = projectId; this.versionId = versionId; this.version = version; + this.fileName = fileName; this.url = url; this.sha1 = sha1; this.sha512 = sha512; this.md5 = md5; + this.dependencies = Collections.unmodifiableList(new ArrayList<>(dependencies)); + this.incompatibleProjects = Collections.unmodifiableList(new ArrayList<>(incompatibleProjects)); + } + public static Resolution unsupported(String projectId) { return new Resolution(false, projectId, null, null, null, null, null, null, Collections.emptyList()); } + } + public enum Action { KEEP, UPDATE, ENABLE, DISABLE, ADD } + public static final class Change { + public final Path installed, destination; + public final String originalSHA256; + public final Action action; + public final Resolution resolution; + Change(Path installed, Path destination, String originalSHA256, Action action, Resolution resolution) { + this.installed = installed; this.destination = destination; this.originalSHA256 = originalSHA256; this.action = action; this.resolution = resolution; + } + @Override public String toString() { return action + " " + (installed == null ? resolution.fileName : installed.getFileName()) + (resolution.version == null ? "" : " -> " + resolution.version); } + } + public static final class Plan { + public final Profile profile; + public final List changes; + public final List errors; + Plan(Profile profile, List changes, List errors) { + this.profile = profile; this.changes = Collections.unmodifiableList(new ArrayList<>(changes)); this.errors = Collections.unmodifiableList(new ArrayList<>(errors)); + } + public List disabled() { List result = new ArrayList<>(); for (Change c : changes) if (c.action == Action.DISABLE) result.add(c); return Collections.unmodifiableList(result); } + public boolean hasChanges() { for (Change c : changes) if (c.action != Action.KEEP) return true; return false; } + public String summary() { StringBuilder text = new StringBuilder(profile.toString()); for (Change c : changes) text.append('\n').append(c); for (String error : errors) text.append("\nERROR: ").append(error); return text.toString(); } + } + private final SharedArtifactCache cache; + private final Resolver resolver; + private final Downloader downloader; + public ProfileUpdater(SharedArtifactCache cache) { this(cache, new AutoPlugProfileResolver(), ProfileUpdater::downloadHttps); } + public ProfileUpdater(SharedArtifactCache cache, Resolver resolver, Downloader downloader) { + this.cache = Objects.requireNonNull(cache); this.resolver = Objects.requireNonNull(resolver); this.downloader = Objects.requireNonNull(downloader); + } + public Plan check(Profile profile) throws IOException { + Path directory = profile.assetsDirectory(); + ProfileFiles.contained(profile.directoryPath, directory); + Files.createDirectories(directory); + List changes = new ArrayList<>(); List errors = new ArrayList<>(); + Set projects = new HashSet<>(); Set destinations = new HashSet<>(); + List dependencies = new ArrayList<>(); + try (DirectoryStream files = Files.newDirectoryStream(directory)) { + List ordered = new ArrayList<>(); for (Path file : files) ordered.add(file); ordered.sort(Comparator.comparing(p -> p.getFileName().toString())); + for (Path installed : ordered) { + String name = installed.getFileName().toString(); + if (!name.endsWith(".jar") && !name.endsWith(".jar.disabled")) continue; + try { + if (!Files.isRegularFile(installed, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Linked/special artifact rejected"); + String originalHash = SharedArtifactCache.hash(installed, "SHA-256"); + Resolution resolution = resolver.resolve(profile, installed); + if (resolution == null) throw new IOException("Resolver returned no result"); + boolean disabled = name.endsWith(".disabled"); + Action action; Path destination; + if (!resolution.compatible) { + action = disabled ? Action.KEEP : Action.DISABLE; + destination = disabled ? installed : installed.resolveSibling(name + ".disabled"); + } else { + validateResolution(resolution); + projects.add(resolution.projectId); dependencies.addAll(resolution.dependencies); + boolean same = resolution.sha512 != null ? resolution.sha512.equalsIgnoreCase(SharedArtifactCache.hash(installed, "SHA-512")) : resolution.sha1 != null ? resolution.sha1.equalsIgnoreCase(SharedArtifactCache.hash(installed, "SHA-1")) : resolution.md5 != null && resolution.md5.equalsIgnoreCase(SharedArtifactCache.hash(installed, "MD5")); + action = disabled ? Action.ENABLE : same ? Action.KEEP : Action.UPDATE; + destination = action == Action.KEEP ? installed : directory.resolve(resolution.fileName); + } + if (!destinations.add(destination)) throw new IOException("Two artifacts target the same filename: " + destination.getFileName()); + changes.add(new Change(installed, destination, originalHash, action, resolution)); + } catch (Exception exception) { errors.add(name + ": " + exception.getMessage()); } + } + } + Set visited = new HashSet<>(); Map pins = new HashMap<>(); + for (int at = 0; at < dependencies.size(); at++) { + Dependency dependency = dependencies.get(at); + String key = dependency.versionId == null ? dependency.projectId : dependency.projectId + ":" + dependency.versionId; + if (!visited.add(key)) continue; + if (dependencies.size() > 512) { errors.add("Dependency graph exceeds 512 entries"); break; } + try { + Resolution resolution = resolver.dependency(profile, dependency); + if (!resolution.compatible) throw new IOException("No compatible required dependency: " + dependency.projectId); + validateResolution(resolution); + // A pinned dependency must be the selected version, not an arbitrary newer project release. + Change existing = null; + for (Change change : changes) if (Objects.equals(change.resolution.projectId, resolution.projectId)) { existing = change; break; } + if (dependency.versionId != null) { + String previous = pins.putIfAbsent(resolution.projectId, dependency.versionId); + if (previous != null && !previous.equals(dependency.versionId)) throw new IOException("Conflicting pinned dependencies for " + resolution.projectId); + } + if (existing != null) { + if (!existing.resolution.compatible || dependency.versionId != null && !dependency.versionId.equals(existing.resolution.versionId)) { + Path destination = directory.resolve(resolution.fileName); + destinations.remove(existing.destination); + if (!destinations.add(destination)) throw new IOException("Pinned dependency filename collides: " + resolution.fileName); + boolean disabled = existing.installed != null && existing.installed.getFileName().toString().endsWith(".disabled"); + changes.set(changes.indexOf(existing), new Change(existing.installed, destination, existing.originalSHA256, existing.installed == null ? Action.ADD : disabled ? Action.ENABLE : Action.UPDATE, resolution)); + dependencies.addAll(resolution.dependencies); + } + continue; + } + Path destination = directory.resolve(resolution.fileName); + if (!destinations.add(destination)) throw new IOException("Dependency filename collides: " + resolution.fileName); + changes.add(new Change(null, destination, null, Action.ADD, resolution)); projects.add(resolution.projectId); dependencies.addAll(resolution.dependencies); + } catch (Exception exception) { errors.add("Dependency " + key + ": " + exception.getMessage()); } + } + Set activeProjects = new HashSet<>(); for (Change change : changes) if (change.resolution.compatible) activeProjects.add(change.resolution.projectId); + for (Change change : changes) for (String incompatible : change.resolution.incompatibleProjects) if (activeProjects.contains(incompatible)) errors.add("Incompatible projects: " + change.resolution.projectId + " and " + incompatible); + return new Plan(profile, changes, errors); + } + public void apply(Plan plan, boolean confirmDisabling) throws IOException { + if (!plan.errors.isEmpty()) throw new IOException("Check failed; no profile changes applied: " + String.join("; ", plan.errors)); + if (!plan.disabled().isEmpty() && !confirmDisabling) throw new IOException("Confirmation required before disabling incompatible artifacts"); + Path profileRoot = plan.profile.directoryPath; + Files.createDirectories(profileRoot); + Path lockPath = profileRoot.resolve(".autoplug-update.lock"); + try (FileChannel channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + FileLock lock; + try { lock = channel.tryLock(); } catch (OverlappingFileLockException busy) { throw new IOException("Profile is already being updated", busy); } + if (lock == null) throw new IOException("Profile is already being updated"); + try { applyLocked(plan); } finally { lock.release(); } + } + } + private void applyLocked(Plan plan) throws IOException { + Path profileRoot = plan.profile.directoryPath; + Path transactions = profileRoot.resolve(".autoplug-transactions"); Files.createDirectories(transactions); + recover(profileRoot, transactions); + List changed = new ArrayList<>(); Set originals = new HashSet<>(); + for (Change change : plan.changes) { + ProfileFiles.contained(profileRoot, change.destination); + if (change.installed != null) { + ProfileFiles.contained(profileRoot, change.installed); + if (!change.originalSHA256.equals(SharedArtifactCache.hash(change.installed, "SHA-256"))) throw new IOException("Artifact changed after check: " + change.installed); + originals.add(change.installed); + } + if (change.action != Action.KEEP) changed.add(change); + } + for (Change change : changed) if (Files.exists(change.destination) && !originals.contains(change.destination)) throw new IOException("Refusing to replace an unreviewed file: " + change.destination); + if (changed.isEmpty()) return; + Properties journal = new Properties(); journal.setProperty("state", "STAGING"); journal.setProperty("count", String.valueOf(changed.size())); + Path sourceFile = profileRoot.resolve("asset-sources.properties"); + boolean sourceExisted = Files.exists(sourceFile, LinkOption.NOFOLLOW_LINKS); + if (sourceExisted && !Files.isRegularFile(sourceFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Invalid asset source metadata file"); + String sourceHash = sourceExisted ? SharedArtifactCache.hash(sourceFile, "SHA-256") : null; + Properties sourceMetadata = new Properties(); + if (sourceExisted) try (InputStream input = Files.newInputStream(sourceFile)) { sourceMetadata.load(input); } + boolean sourceChanged = false; + Path transaction = Files.createTempDirectory(transactions, "update-"); + List staged = new ArrayList<>(); + boolean prepared = false; + try { + for (int index = 0; index < changed.size(); index++) { + Change change = changed.get(index); + journal.setProperty(index + ".destination", profileRoot.relativize(change.destination).toString()); + if (change.installed != null) { + journal.setProperty(index + ".original", profileRoot.relativize(change.installed).toString()); + Files.copy(change.installed, transaction.resolve(index + ".backup")); + } + Path cached = null; + if (change.action == Action.DISABLE) cached = cache.store(change.installed); + else { + Path download = transaction.resolve(index + ".jar"); downloader.download(change.resolution.url, download); + verify(download, change.resolution); cached = cache.store(download); + } + staged.add(cached); + if (change.resolution.compatible) { + String project = change.resolution.projectId, provider = "modrinth", id = project; + int colon = project.indexOf(':'); + if (colon != -1) { provider = project.substring(0, colon); id = project.substring(colon + 1); } + sourceMetadata.setProperty(change.destination.getFileName().toString() + "." + provider, id); + sourceChanged = true; + } + } + if (sourceChanged) { + int metadataIndex = changed.size(); journal.setProperty("count", String.valueOf(metadataIndex + 1)); journal.setProperty(metadataIndex + ".destination", "asset-sources.properties"); + if (sourceExisted) { journal.setProperty(metadataIndex + ".original", "asset-sources.properties"); Files.copy(sourceFile, transaction.resolve(metadataIndex + ".backup")); } + try (OutputStream output = Files.newOutputStream(transaction.resolve("asset-sources.part"))) { sourceMetadata.store(output, "Verified artifact project identities; explicit entries may be added here"); } + } + // Network staging can take time: verify reviewed originals a second time immediately before committing. + for (Change change : changed) if (change.installed != null && !change.originalSHA256.equals(SharedArtifactCache.hash(change.installed, "SHA-256"))) throw new IOException("Artifact changed during download staging: " + change.installed); + for (Change change : changed) if (Files.exists(change.destination) && !originals.contains(change.destination)) throw new IOException("Unreviewed destination appeared during download staging: " + change.destination); + if (sourceChanged && (sourceExisted ? !sourceHash.equals(SharedArtifactCache.hash(sourceFile, "SHA-256")) : Files.exists(sourceFile))) throw new IOException("Asset source metadata changed during download staging"); + journal.setProperty("state", "PREPARED"); writeJournal(transaction, journal); prepared = true; + // Remove reviewed original paths before installing new names; this also handles legitimate filename swaps. + for (Change change : changed) if (change.installed != null) Files.delete(change.installed); + for (int index = 0; index < changed.size(); index++) { + Change change = changed.get(index); + cache.link(staged.get(index), change.destination); + } + if (sourceChanged) ProfileFiles.move(transaction.resolve("asset-sources.part"), sourceFile); + journal.setProperty("state", "COMMITTED"); writeJournal(transaction, journal); + } catch (IOException | RuntimeException failure) { + if (prepared) try { rollback(profileRoot, transaction, journal); } catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); throw failure; } + ProfileFiles.deleteTree(transactions, transaction); throw failure; + } + ProfileFiles.deleteTree(transactions, transaction); + } + private static void recover(Path profileRoot, Path transactions) throws IOException { + try (DirectoryStream directories = Files.newDirectoryStream(transactions, "update-*")) { + for (Path transaction : directories) { + ProfileFiles.contained(transactions, transaction); + Path file = transaction.resolve("journal.properties"); + if (Files.exists(file)) { + Properties journal = new Properties(); try (InputStream input = Files.newInputStream(file)) { journal.load(input); } + if ("PREPARED".equals(journal.getProperty("state"))) rollback(profileRoot, transaction, journal); + } + ProfileFiles.deleteTree(transactions, transaction); + } + } + } + private static void rollback(Path root, Path transaction, Properties journal) throws IOException { + int count = Integer.parseInt(journal.getProperty("count")); + if (count < 1 || count > 4096) throw new IOException("Invalid profile transaction size"); + Map originals = new HashMap<>(); + for (int index = 0; index < count; index++) { + String original = journal.getProperty(index + ".original"); + if (original != null) originals.put(ProfileFiles.contained(root, root.resolve(original)), transaction.resolve(index + ".backup")); + } + IOException failures = null; + for (int index = 0; index < count; index++) { + try { + Path destination = ProfileFiles.contained(root, root.resolve(journal.getProperty(index + ".destination"))); + Path backup = originals.get(destination); + // An original which could not be removed (for example an open Windows jar) already has the correct bytes. + if (backup != null && sameBytes(destination, backup)) continue; + Files.deleteIfExists(destination); + } catch (IOException failure) { if (failures == null) failures = failure; else failures.addSuppressed(failure); } + } + for (Map.Entry original : originals.entrySet()) try { + if (!sameBytes(original.getKey(), original.getValue())) Files.copy(original.getValue(), original.getKey(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException failure) { if (failures == null) failures = failure; else failures.addSuppressed(failure); } + if (failures != null) throw failures; + } + private static boolean sameBytes(Path current, Path backup) throws IOException { + return Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS) && Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) + && SharedArtifactCache.hash(current, "SHA-256").equals(SharedArtifactCache.hash(backup, "SHA-256")); + } + private static void writeJournal(Path transaction, Properties journal) throws IOException { + Path temporary = transaction.resolve("journal.part"); + try (OutputStream output = Files.newOutputStream(temporary)) { journal.store(output, "Profile transaction"); } + ProfileFiles.move(temporary, transaction.resolve("journal.properties")); + } + private static void validateResolution(Resolution resolution) throws IOException { + if (resolution.fileName == null || !resolution.fileName.endsWith(".jar") || !Paths.get(resolution.fileName).getFileName().toString().equals(resolution.fileName) || resolution.fileName.contains("/") || resolution.fileName.contains("\\")) throw new IOException("Invalid artifact filename"); + if (resolution.url == null || resolution.projectId == null) throw new IOException("Missing artifact identity or download URL"); + } + private static void verify(Path download, Resolution resolution) throws IOException { + if (resolution.sha1 != null && !resolution.sha1.equalsIgnoreCase(SharedArtifactCache.hash(download, "SHA-1"))) throw new IOException("Artifact SHA-1 mismatch"); + if (resolution.sha512 != null && !resolution.sha512.equalsIgnoreCase(SharedArtifactCache.hash(download, "SHA-512"))) throw new IOException("Artifact SHA-512 mismatch"); + if (resolution.md5 != null && !resolution.md5.equalsIgnoreCase(SharedArtifactCache.hash(download, "MD5"))) throw new IOException("Artifact MD5 mismatch"); + try (ZipFile jar = new ZipFile(download.toFile())) { if (!jar.entries().hasMoreElements()) throw new IOException("Empty artifact jar"); } + } + private static void downloadHttps(String value, Path destination) throws IOException { + URL url = new URL(value); + if (!"https".equalsIgnoreCase(url.getProtocol())) throw new IOException("Artifact download must use HTTPS"); + URLConnection connection = url.openConnection(); connection.setConnectTimeout(15000); connection.setReadTimeout(30000); connection.setRequestProperty("User-Agent", "AutoPlug-Client/10.2.0 (native profiles)"); + try (InputStream input = connection.getInputStream(); OutputStream output = Files.newOutputStream(destination)) { + byte[] buffer = new byte[65536]; long total = 0; int read; + while ((read = input.read(buffer)) != -1) { total += read; if (total > 1024L * 1024 * 1024) throw new IOException("Artifact exceeds 1 GiB"); output.write(buffer, 0, read); } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/SharedArtifactCache.java b/src/main/java/com/osiris/autoplug/client/profiles/SharedArtifactCache.java new file mode 100644 index 00000000..8b99eb1c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/SharedArtifactCache.java @@ -0,0 +1,58 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import java.io.*; +import java.nio.file.*; +import java.security.*; + +/** Content-addressed artifacts. Installations replace links rather than overwriting shared bytes. */ +public final class SharedArtifactCache { + private final Path root; + public SharedArtifactCache(Path root) throws IOException { + this.root = root.toAbsolutePath().normalize(); + Files.createDirectories(this.root); + if (Files.isSymbolicLink(this.root)) throw new IOException("Cache cannot be a symbolic link"); + } + public Path getRoot() { return root; } + public Path store(Path source) throws IOException { + if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Artifact is not a regular file: " + source); + String hash = hash(source, "SHA-256"); + Path destination = root.resolve(hash + ".jar"); + if (Files.exists(destination)) { + if (!hash.equals(hash(destination, "SHA-256"))) throw new IOException("Corrupt shared-cache artifact: " + destination); + return destination; + } + Path temporary = Files.createTempFile(root, "artifact-", ".part"); + try { + Files.copy(source, temporary, StandardCopyOption.REPLACE_EXISTING); + if (!hash.equals(hash(temporary, "SHA-256"))) throw new IOException("Artifact changed during cache import"); + ProfileFiles.move(temporary, destination); + return destination; + } finally { Files.deleteIfExists(temporary); } + } + public void link(Path cached, Path destination) throws IOException { + cached = cached.toAbsolutePath().normalize(); + if (!cached.getParent().equals(root) || !cached.getFileName().toString().matches("[a-f0-9]{64}\\.jar")) throw new IOException("Invalid cached artifact path"); + String expected = cached.getFileName().toString().substring(0, 64); + if (!expected.equals(hash(cached, "SHA-256"))) throw new IOException("Shared cache artifact checksum mismatch"); + Files.createDirectories(destination.toAbsolutePath().getParent()); + Path temporary = destination.resolveSibling(destination.getFileName() + ".link-" + java.util.UUID.randomUUID()); + try { + try { Files.createLink(temporary, cached); } + catch (IOException | UnsupportedOperationException exception) { Files.copy(cached, temporary); } + ProfileFiles.move(temporary, destination); + } finally { Files.deleteIfExists(temporary); } + } + public static String hash(Path file, String algorithm) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[65536]; int read; + while ((read = input.read(buffer)) != -1) digest.update(buffer, 0, read); + } + StringBuilder result = new StringBuilder(); + for (byte part : digest.digest()) result.append(String.format("%02x", part & 255)); + return result.toString(); + } catch (NoSuchAlgorithmException impossible) { throw new IOException(impossible); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java index 6506b96c..920c73a0 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java @@ -33,6 +33,44 @@ public class CurseForgeAPI { private final String baseUrl = "https://api.curseforge.com/v1"; + /** Explicit profile context. Reuses the existing configured API client/key; never performs fingerprint POSTs. */ + public JsonObject compatibleProfileRelease(String projectId, String loader, String gameVersion) throws Exception { + if (projectId == null || !projectId.matches("[0-9]{1,10}")) throw new IOException("Invalid CurseForge project id"); + int loaderType; + switch (loader.toLowerCase(java.util.Locale.ROOT)) { + case "forge": loaderType = 1; break; + case "fabric": loaderType = 4; break; + case "quilt": loaderType = 5; break; + case "neoforge": loaderType = 6; break; + default: throw new IOException("Unsupported CurseForge profile loader: " + loader); + } + JsonObject selected = null; + for (int index = 0; index < 10000; index += 50) { + String url = baseUrl + "/mods/" + projectId + "/files?gameVersion=" + java.net.URLEncoder.encode(gameVersion, "UTF-8") + "&modLoaderType=" + loaderType + "&pageSize=50&index=" + index; + JsonObject response = new CurseForgeJson().getJsonObject(url); + JsonArray files = response.getAsJsonArray("data"); + JsonObject candidate = selectCompatibleProfileRelease(files, loader, gameVersion); + if (candidate != null && (selected == null || Instant.parse(candidate.get("fileDate").getAsString()).isAfter(Instant.parse(selected.get("fileDate").getAsString())))) selected = candidate; + int total = response.has("pagination") ? response.getAsJsonObject("pagination").get("totalCount").getAsInt() : index + files.size(); + if (files.size() < 50 || index + files.size() >= total) return selected; + } + throw new IOException("CurseForge file pagination exceeded the documented limit"); + } + public static JsonObject selectCompatibleProfileRelease(JsonArray files, String loader, String gameVersion) { + JsonObject selected = null; + for (JsonElement element : files) { + JsonObject file = element.getAsJsonObject(); boolean game = false, targetLoader = false; + if (file.has("isAvailable") && !file.get("isAvailable").getAsBoolean()) continue; + for (JsonElement tag : file.getAsJsonArray("gameVersions")) { + if (gameVersion.equals(tag.getAsString())) game = true; + if (loader.equalsIgnoreCase(tag.getAsString())) targetLoader = true; + } + if (!game || !targetLoader) continue; + if (selected == null || Instant.parse(file.get("fileDate").getAsString()).isAfter(Instant.parse(selected.get("fileDate").getAsString()))) selected = file; + } + return selected; + } + /** * Requires curseforgeId not null. */ diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeJson.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeJson.java index 2678d454..3f74b69e 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeJson.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeJson.java @@ -43,6 +43,7 @@ public JsonElement getJsonElement(String input_url) throws IOException, HttpErro con.addRequestProperty("User-Agent", "AutoPlug-Client - Contact: " + GD.OFFICIAL_WEBSITE); con.addRequestProperty("x-api-key", key); con.setConnectTimeout(1000); + con.setReadTimeout(30000); con.connect(); if (con.getResponseCode() == 200) { diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java index d45039d8..a96da621 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java @@ -21,7 +21,64 @@ public class ModrinthAPI { - private final String baseUrl = "https://api.modrinth.com/v2"; + private final String baseUrl; + + public ModrinthAPI() { this("https://api.modrinth.com/v2"); } + public ModrinthAPI(String baseUrl) { this.baseUrl = baseUrl; } + + /** Identify exact installed bytes, rather than guessing a project from a display name. A 404 is a genuine unknown file. */ + public JsonObject identifyProfileFile(java.nio.file.Path file) throws java.io.IOException { + String hash = com.osiris.autoplug.client.profiles.SharedArtifactCache.hash(file, "SHA-1"); + com.google.gson.JsonElement result = profileGet("/version_file/" + hash + "?algorithm=sha1", true); + return result == null ? null : result.getAsJsonObject(); + } + public JsonObject profileVersion(String versionId) throws java.io.IOException { + if (versionId == null || !versionId.matches("[A-Za-z0-9_-]{1,128}")) throw new java.io.IOException("Invalid Modrinth version id"); + com.google.gson.JsonElement result = profileGet("/version/" + versionId, true); + return result == null ? null : result.getAsJsonObject(); + } + + /** Explicit target version and loader resolution also works for downgrades, independent of local timestamps. */ + public JsonObject compatibleProfileRelease(String projectId, List loaders, String mcVersion, String pinnedVersionId) throws java.io.IOException { + if (projectId == null || !projectId.matches("[A-Za-z0-9_-]{1,128}")) throw new java.io.IOException("Invalid Modrinth project id"); + if (pinnedVersionId != null) { + if (!pinnedVersionId.matches("[A-Za-z0-9_-]{1,128}")) throw new java.io.IOException("Invalid Modrinth version id"); + com.google.gson.JsonElement result = profileGet("/version/" + pinnedVersionId, true); + if (result == null) return null; + JsonObject version = result.getAsJsonObject(); + return projectId.equals(version.get("project_id").getAsString()) && profileCompatible(version, loaders, mcVersion) ? version : null; + } + String query = "/project/" + projectId + "/version?loaders=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(loaders), "UTF-8") + + "&game_versions=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(java.util.Collections.singletonList(mcVersion)), "UTF-8"); + com.google.gson.JsonElement result = profileGet(query, true); + if (result == null) return null; + JsonObject selected = null; + for (com.google.gson.JsonElement element : result.getAsJsonArray()) { + JsonObject candidate = element.getAsJsonObject(); + if (!profileCompatible(candidate, loaders, mcVersion)) continue; + if (selected == null || java.time.Instant.parse(candidate.get("date_published").getAsString()).isAfter(java.time.Instant.parse(selected.get("date_published").getAsString()))) selected = candidate; + } + return selected; + } + private static boolean profileCompatible(JsonObject version, List loaders, String mcVersion) { + boolean game = false, loader = false; + for (com.google.gson.JsonElement value : version.getAsJsonArray("game_versions")) if (mcVersion.equals(value.getAsString())) game = true; + for (com.google.gson.JsonElement value : version.getAsJsonArray("loaders")) if (loaders.contains(value.getAsString())) loader = true; + return game && loader; + } + private com.google.gson.JsonElement profileGet(String relative, boolean missingAllowed) throws java.io.IOException { + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(baseUrl + relative).openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(30000); + connection.setRequestProperty("User-Agent", "AutoPlug-Client/10.2.0 (native profile updater)"); connection.setRequestProperty("Accept", "application/json"); + try { + int status = connection.getResponseCode(); + if (status == 404 && missingAllowed) return null; + if (status != 200) throw new java.io.IOException("Modrinth API returned HTTP " + status); + try (java.io.Reader input = new java.io.InputStreamReader(connection.getInputStream(), java.nio.charset.StandardCharsets.UTF_8)) { + return com.google.gson.JsonParser.parseReader(input); + } catch (RuntimeException invalid) { throw new java.io.IOException("Invalid Modrinth API response", invalid); } + } finally { connection.disconnect(); } + } private boolean isInt(String s) { try { diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java index e991ca4d..fabb9ec4 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java @@ -36,6 +36,24 @@ import java.util.concurrent.Future; public class TaskModsUpdater extends BThread { + private com.osiris.autoplug.client.profiles.Profile profileContext; + private com.osiris.autoplug.client.profiles.ProfileUpdater profileUpdater; + private boolean profileApply, profileConfirmDisabling; + + public TaskModsUpdater(String name, BThreadManager manager, com.osiris.autoplug.client.profiles.Profile profile, + com.osiris.autoplug.client.profiles.ProfileUpdater updater, boolean apply, boolean confirmDisabling) { + this(name, manager); + if (profile.type == com.osiris.autoplug.client.profiles.Profile.Type.PLUGINS) throw new IllegalArgumentException("Expected a MODS or MODS_SERVER profile"); + profileContext = profile; profileUpdater = updater; profileApply = apply; profileConfirmDisabling = confirmDisabling; + } + public static com.osiris.autoplug.client.profiles.ProfileUpdater.Plan processProfile(com.osiris.autoplug.client.profiles.Profile profile, + com.osiris.autoplug.client.profiles.ProfileUpdater updater, boolean apply, boolean confirmDisabling, java.util.function.Consumer output) throws java.io.IOException { + if (profile.type == com.osiris.autoplug.client.profiles.Profile.Type.PLUGINS) throw new IllegalArgumentException("Expected a MODS or MODS_SERVER profile"); + com.osiris.autoplug.client.profiles.ProfileUpdater.Plan plan = updater.check(profile); + output.accept(plan.summary()); + if (apply) updater.apply(plan, confirmDisabling); + return plan; + } //private final ModsUpdateResultConnection con; private final String notifyProfile = "NOTIFY"; private final String manualProfile = "MANUAL"; @@ -64,6 +82,11 @@ public TaskModsUpdater(String name, BThreadManager manager) { @Override public void runAtStart() throws Exception { + if (profileContext != null) { + processProfile(profileContext, profileUpdater, profileApply, profileConfirmDisabling, this::addInfo); + finish("Profile " + profileContext.id + (profileApply ? " updated." : " checked.")); + return; + } updaterConfig = new UpdaterConfig(); if (!updaterConfig.mods_updater.asBoolean()) { skip(); diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/InstalledPluginLoader.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/InstalledPluginLoader.java index ceaca588..e97e9abc 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/InstalledPluginLoader.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/InstalledPluginLoader.java @@ -48,6 +48,19 @@ public List getLoaderList() { } return List.of(); } + /** Exact profile target: a Bukkit/Spigot instance must not receive a Paper-only or Purpur-only jar. */ + public static List forProfile(String software) { + switch (software.toLowerCase(Locale.ROOT)) { + case "bukkit": return List.of("bukkit"); + case "spigot": return List.of("spigot", "bukkit"); + case "paper": return List.of("paper", "spigot", "bukkit"); + case "purpur": return List.of("purpur", "paper", "spigot", "bukkit"); + case "velocity": return List.of("velocity"); + case "bungeecord": return List.of("bungeecord"); + case "waterfall": return List.of("waterfall", "bungeecord"); + default: return new InstalledPluginLoader(software).getLoaderList(); + } + } public enum Loader { VELOCITY, diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java index 7d0bf079..de1cae51 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java @@ -26,6 +26,14 @@ public class ResourceFinder { + /** Profile-scoped resolution shares AutoPlug's Modrinth service and never consults process-global loader/config state. */ + public com.google.gson.JsonObject findProfileRelease(ModrinthAPI api, String projectId, List loaders, String gameVersion, String pinnedVersionId) throws java.io.IOException { + return api.compatibleProfileRelease(projectId, loaders, gameVersion, pinnedVersionId); + } + public com.google.gson.JsonObject findProfileCurseForgeRelease(String projectId, String loader, String gameVersion) throws Exception { + return new CurseForgeAPI().compatibleProfileRelease(projectId, loader, gameVersion); + } + /** * If the spigot/bukkit id is not given this type of search * based on the plugins' name and author will be executed.
@@ -79,6 +87,11 @@ public SearchResult findPluginByBukkitId(MinecraftPlugin plugin) { sr.plugin = plugin; return sr; } + public SearchResult findProfilePluginByBukkitId(MinecraftPlugin plugin, String gameVersion) { + SearchResult result = new BukkitSearchById().searchCompatible(plugin, gameVersion); + result.plugin = plugin; + return result; + } public SearchResult findPluginByModrinthId(List loaders, MinecraftPlugin plugin, String mcVersion) { SearchResult sr = new ModrinthAPI().searchUpdatePlugin(loaders, plugin, mcVersion); sr.plugin = plugin; diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java index cb3fafd1..e8c58fa9 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java @@ -48,6 +48,24 @@ import java.util.concurrent.Future; public class TaskPluginsUpdater extends BThread { + private com.osiris.autoplug.client.profiles.Profile profileContext; + private com.osiris.autoplug.client.profiles.ProfileUpdater profileUpdater; + private boolean profileApply, profileConfirmDisabling; + + public TaskPluginsUpdater(String name, BThreadManager manager, com.osiris.autoplug.client.profiles.Profile profile, + com.osiris.autoplug.client.profiles.ProfileUpdater updater, boolean apply, boolean confirmDisabling) { + this(name, manager); + if (profile.type != com.osiris.autoplug.client.profiles.Profile.Type.PLUGINS) throw new IllegalArgumentException("Expected a PLUGINS profile"); + profileContext = profile; profileUpdater = updater; profileApply = apply; profileConfirmDisabling = confirmDisabling; + } + public static com.osiris.autoplug.client.profiles.ProfileUpdater.Plan processProfile(com.osiris.autoplug.client.profiles.Profile profile, + com.osiris.autoplug.client.profiles.ProfileUpdater updater, boolean apply, boolean confirmDisabling, java.util.function.Consumer output) throws java.io.IOException { + if (profile.type != com.osiris.autoplug.client.profiles.Profile.Type.PLUGINS) throw new IllegalArgumentException("Expected a PLUGINS profile"); + com.osiris.autoplug.client.profiles.ProfileUpdater.Plan plan = updater.check(profile); + output.accept(plan.summary()); + if (apply) updater.apply(plan, confirmDisabling); + return plan; + } //private final PluginsUpdateResultConnection con; private final String notifyProfile = "NOTIFY"; private final String manualProfile = "MANUAL"; @@ -77,6 +95,11 @@ public TaskPluginsUpdater(String name, BThreadManager manager) { @Override public void runAtStart() throws Exception { + if (profileContext != null) { + processProfile(profileContext, profileUpdater, profileApply, profileConfirmDisabling, this::addInfo); + finish("Profile " + profileContext.id + (profileApply ? " updated." : " checked.")); + return; + } pluginsConfig = new PluginsConfig(); pluginsConfig.load(); // No lock needed, since there are no other threads that access this file String name = pluginsConfig.getFileNameWithoutExt(); diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java index 9c6c9d26..f90495e9 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java @@ -38,6 +38,8 @@ public static boolean isMatchFound(SearchResult sr) { public Type type; public Exception exception; public String fileName; + /** Optional integrity value supplied by the Bukkit file API. */ + public String expectedMD5; /** * @param type All codes:
diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/bukkit/BukkitSearchById.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/bukkit/BukkitSearchById.java index b2e6f337..4610dc53 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/bukkit/BukkitSearchById.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/bukkit/BukkitSearchById.java @@ -21,6 +21,36 @@ public class BukkitSearchById { + /** Select a declared-compatible target release, including older game versions, without guessing from latest. */ + public SearchResult searchCompatible(MinecraftPlugin plugin, String gameVersion) { + try { + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL("https://api.curseforge.com/servermods/files?projectIds=" + plugin.getBukkitId()).openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(30000); connection.setRequestProperty("User-Agent", "AutoPlug-Client/10.2.0"); + JsonArray versions; + try { + if (connection.getResponseCode() != 200) throw new java.io.IOException("Bukkit API returned HTTP " + connection.getResponseCode()); + try (java.io.Reader reader = new java.io.InputStreamReader(connection.getInputStream(), java.nio.charset.StandardCharsets.UTF_8)) { versions = com.google.gson.JsonParser.parseReader(reader).getAsJsonArray(); } + } finally { connection.disconnect(); } + JsonObject selected = null; + for (com.google.gson.JsonElement value : versions) { + JsonObject release = value.getAsJsonObject(); + if (!release.has("gameVersion") || release.get("gameVersion").isJsonNull()) continue; + Matcher game = Pattern.compile("([0-9]+(?:\\.[0-9]+){1,2})").matcher(release.get("gameVersion").getAsString()); + if (!game.find()) continue; + String declared = game.group(1); + if (declared.equals(gameVersion) || gameVersion.startsWith(declared + ".")) selected = release; + } + if (selected == null) return new SearchResult(plugin, SearchResult.Type.RESOURCE_NOT_FOUND, null, null, null, null, String.valueOf(plugin.getBukkitId()), false); + SearchResult result = new SearchResult(plugin, SearchResult.Type.UPDATE_AVAILABLE, selected.get("name").getAsString(), selected.get("downloadUrl").getAsString(), ".jar", null, String.valueOf(plugin.getBukkitId()), false); + result.fileName = selected.get("fileName").getAsString(); + if (selected.has("md5") && !selected.get("md5").isJsonNull()) result.expectedMD5 = selected.get("md5").getAsString(); + if (!result.fileName.endsWith(".jar")) throw new java.io.IOException("Compatible Bukkit release is not a jar"); + return result; + } catch (Exception exception) { + SearchResult result = new SearchResult(plugin, SearchResult.Type.API_ERROR, null, null, null, null, String.valueOf(plugin.getBukkitId()), false); result.exception = exception; return result; + } + } + public SearchResult search(MinecraftPlugin plugin) { AL.debug(this.getClass(), "[" + plugin.getName() + "] Performing bukkit search by id"); int bukkitId = plugin.getBukkitId(); diff --git a/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java b/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java index 4c14b8ef..0abbad80 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java +++ b/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java @@ -33,14 +33,10 @@ public ClientPanel(Container parent) throws Exception { tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); if (GD.TARGET == Target.MINECRAFT_CLIENT || GD.TARGET == Target.MINECRAFT_SERVER) { - SettingsPanel servers = new SettingsPanel(tabs); - tabs.addTab("Servers", servers); // TODO list of favorite servers with play/join button which auto-selects - // (TODO) and launches the correct minecraft client version - - SettingsPanel profiles = new SettingsPanel(tabs); - tabs.addTab("Profiles", profiles); // TODO game profile containing settings and mods - // (TODO) is not tied to a minecraft version, meaning internally - // (TODO) mods/settings will be down/upgraded to match the launched version + com.osiris.autoplug.client.clientapp.NativeClientService service = com.osiris.autoplug.client.clientapp.NativeClientService.getDefault(); + tabs.addTab("Servers", new com.osiris.autoplug.client.ui.client.ServerBrowserPanel(service)); + tabs.addTab("Profiles", new com.osiris.autoplug.client.ui.client.ProfilesPanel(service)); + tabs.addTab("Client settings", new com.osiris.autoplug.client.ui.client.ClientSettingsPanel(service)); MinecraftModsPanel minecraftMods = new MinecraftModsPanel(tabs); tabs.addTab("Mods", minecraftMods); diff --git a/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java b/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java index 60c29f6b..e31b53d9 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java +++ b/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java @@ -17,6 +17,9 @@ import com.osiris.betterlayout.BLayout; import com.osiris.betterlayout.utils.UIDebugWindow; import com.osiris.jlib.logger.AL; +import com.osiris.autoplug.client.clientapp.NativeClientService; +import com.osiris.autoplug.client.ui.client.ClientDashboard; +import com.osiris.autoplug.client.ui.client.ServerManagerPanel; import javax.swing.*; import java.awt.*; @@ -34,6 +37,12 @@ public class MainWindow extends JFrame { public static MainWindow GET = null; public static final Object lockUI = new Object(); public TrayIcon trayIcon; + private ServerManagerPanel serverManager; + public static void open(GeneralConfig config) { + SwingUtilities.invokeLater(() -> { + try { new MainWindow(config); } catch (Exception exception) { AL.warn("Could not open dashboard", exception); } + }); + } public MainWindow(GeneralConfig generalConfig) throws Exception { synchronized (lockUI){ @@ -43,7 +52,7 @@ public MainWindow(GeneralConfig generalConfig) throws Exception { GET = this; } initTheme(generalConfig); - start(); + try { start(); } catch (Exception failure) { close(); throw failure; } this.addKeyListener(new KeyListener() { @Override public void keyTyped(KeyEvent e) { @@ -84,26 +93,34 @@ public void initTheme(GeneralConfig generalConfig) { } public void close() { - if (SystemTray.isSupported()) + if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(this::close); return; } + if (serverManager != null) serverManager.release(); + if (getContentPane() instanceof ClientDashboard) ((ClientDashboard)getContentPane()).release(); + if (SystemTray.isSupported() && trayIcon != null) SystemTray.getSystemTray().remove(trayIcon); this.dispose(); GET = null; } public void start() throws Exception { + if (SwingUtilities.isEventDispatchThread()) initUI(); + else SwingUtilities.invokeAndWait(() -> { + try { initUI(); } catch (Exception exception) { throw new IllegalStateException(exception); } + }); if (SystemTray.isSupported()) { SystemTray tray = SystemTray.getSystemTray(); File icon = new File(GD.WORKING_DIR + "/autoplug/system/icon.png"); if (!icon.exists()) { icon.getParentFile().mkdirs(); - icon.createNewFile(); - InputStream link = (getClass().getResourceAsStream("/autoplug-icon.png")); - Files.copy(link, icon.toPath()); + try (InputStream link = getClass().getResourceAsStream("/autoplug-icon.png")) { + if (link == null) throw new java.io.IOException("Missing application icon"); + Files.copy(link, icon.toPath()); + } } Image image = Toolkit.getDefaultToolkit().getImage(icon.getAbsolutePath()); trayIcon = new TrayIcon(image, "AutoPlug", null); trayIcon.addMouseListener(new MyMouseListener().onClick(event -> { - this.setVisible(true); + openDashboard(); })); trayIcon.setImageAutoSize(true); try { @@ -128,48 +145,26 @@ public void start() throws Exception { */ this.setIconImage(image); - initUI(); - } else throw new Exception("Failed to create system tray GUI: Not supported on your system."); + } else openDashboard(); + } + + public void openDashboard() { + SwingUtilities.invokeLater(() -> { setVisible(true); setExtendedState(getExtendedState() & ~Frame.ICONIFIED); toFront(); requestFocus(); }); } private void initUI() throws Exception { // TODO dont stop full autoplug when this window is closed this.setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); - this.setName("AutoPlug-Tray"); - this.setTitle("AutoPlug-Tray"); - this.setUndecorated(true); + this.setName("AutoPlug-Client-Dashboard"); + this.setTitle("AutoPlug • Client & Server Dashboard"); + this.setUndecorated(false); int screenWidth = Toolkit.getDefaultToolkit().getScreenSize().width, screenHeight = Toolkit.getDefaultToolkit().getScreenSize().height; - int width = (int) (screenWidth / 1.5), height = screenHeight / 2; - this.setShape(new RoundRectangle2D.Double(0, 0, width, height, 20, 20)); + int width = Math.min(1180, screenWidth - 80), height = Math.min(780, screenHeight - 80); this.setLocation((screenWidth / 2) - (width / 2), (screenHeight / 2) - (height / 2)); // Position frame in mid of screen this.setSize(width, height); + this.setMinimumSize(new Dimension(800, 540)); this.setVisible(false); - - BLayout thisLy = new BLayout(this); - this.setContentPane(thisLy); - thisLy.access(() -> { - // Add stuff to main window - JLabel titleAutoPlug = new JLabel(), titleTray = new JLabel(); - titleAutoPlug.setText("AutoPlug"); - titleAutoPlug.putClientProperty("FlatLaf.style", "font: 200% $semibold.font"); - thisLy.addH(titleAutoPlug); - - titleTray.setText(" | Tray"); - titleTray.putClientProperty("FlatLaf.style", "font: 200% $light.font"); - thisLy.addH(titleTray).delPadding().paddingTop(); - - JTabbedPane mainTab = new JTabbedPane(); - thisLy.addV(mainTab).height(80).widthFull(); - mainTab.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); - - // Tab panels/layouts - try { - mainTab.addTab("Server", new ServerPanel(mainTab)); - mainTab.addTab("Client", new ClientPanel(mainTab)); - //tabbedPane.addChangeListener(e -> selectedTabChanged()); - } catch (Exception e) { - AL.warn(e); - } - }); + serverManager = new ServerManagerPanel(); + this.setContentPane(new ClientDashboard(NativeClientService.getDefault(), serverManager)); } } diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/ClientDashboard.java b/src/main/java/com/osiris/autoplug/client/ui/client/ClientDashboard.java new file mode 100644 index 00000000..224da4fa --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/ClientDashboard.java @@ -0,0 +1,26 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.clientapp.NativeClientService; +import javax.swing.*; + +/** The actual dashboard also works without a system tray (for example on Linux desktops). */ +public final class ClientDashboard extends JTabbedPane { + private final ClientSettingsPanel settings; + private final VirtualWorldsPanel worlds; + public ClientDashboard(NativeClientService service, JComponent serverManager) { + this(service, serverManager, true); + } + public ClientDashboard(NativeClientService service, JComponent serverManager, boolean importVanillaOnOpen) { + setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); + ServerBrowserPanel browser = new ServerBrowserPanel(service); addTab("Server Browser", browser); + VirtualWorldsPanel worldPanel = null; + try { worldPanel = new VirtualWorldsPanel(service); addTab("Singleplayer", worldPanel); } + catch (java.io.IOException unavailable) { addTab("Singleplayer", new JLabel("Could not load local worlds: " + unavailable.getMessage())); } + worlds = worldPanel; + addTab("Profiles", new ProfilesPanel(service)); + if (serverManager != null) addTab("Server Manager", serverManager); + settings = new ClientSettingsPanel(service); addTab("Settings", settings); + if (importVanillaOnOpen) browser.importDefaultIfPresent(); + } + public void release() { settings.release(); if (worlds != null) worlds.release(); } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/ClientSettingsPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/ClientSettingsPanel.java new file mode 100644 index 00000000..e7da9ede --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/ClientSettingsPanel.java @@ -0,0 +1,78 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.clientapp.*; +import com.osiris.autoplug.client.launcher.*; +import javax.swing.*; +import java.awt.*; +import java.awt.event.*; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ClientSettingsPanel extends DashboardPanel { + private final NativeClientService service; + private final JTextField offlineName = new JTextField(24), clientId = new JTextField(36), template = new JTextField(24); + private final JCheckBox offline = new JCheckBox("Use offline mode for local testing"); + private final JTextField worldPort = new JTextField(8); + private final JCheckBox upnp = new JCheckBox("Allow UPnP when Play & share is chosen"); + private final JTextField[] java = {new JTextField(36), new JTextField(36), new JTextField(36), new JTextField(36)}; + private final JLabel account = new JLabel(); + private AtomicBoolean cancelled; + private long authenticationAttempt; + public void release() { if (cancelled != null) cancelled.set(true); service.cancelAuthentication(authenticationAttempt); } + public ClientSettingsPanel(NativeClientService service) { + this.service = service; + offlineName.setText(service.settings.offlineName); offline.setSelected(service.settings.offlineMode); clientId.setText(service.settings.microsoftClientId); template.setText(service.settings.templateId); + worldPort.setText(Integer.toString(service.settings.worldPort())); upnp.setSelected(service.settings.upnpAllowed()); + int[] majors = {8, 17, 21, 25}; for (int i = 0; i < majors.length; i++) java[i].setText(service.settings.javaPaths.getOrDefault(majors[i], "")); + JPanel form = new JPanel(new GridBagLayout()); int row = 0; + row(form, row++, "Account", account); row(form, row++, "Testing", offline); row(form, row++, "Offline player name", offlineName); + row(form, row++, "Microsoft public application ID", clientId); row(form, row++, "Default client template ID", template); + row(form, row++, "World port (0 = automatic)", worldPort); row(form, row++, "Router sharing", upnp); + for (int i = 0; i < majors.length; i++) row(form, row++, "Java " + majors[i] + " executable (optional)", java[i]); + JLabel note = new JLabel("Leave Java paths empty for automatic, verified runtime downloads.
Microsoft sign-in uses your registered public client application and a licensed account.
Access and refresh tokens stay in memory; sign in again after restarting AutoPlug.
Offline mode cannot join servers that require online authentication.
UPnP is requested only by Play & share; it uses a temporary TCP lease.
Port 0 selects a free port; a fixed port must be available. Changes apply to new launches."); + GridBagConstraints space = new GridBagConstraints(); space.gridx = 0; space.gridy = row; space.gridwidth = 2; space.fill = GridBagConstraints.HORIZONTAL; space.insets = new Insets(12, 0, 0, 0); form.add(note, space); + JPanel top = new JPanel(new BorderLayout()); top.add(form, BorderLayout.NORTH); add(new JScrollPane(top), BorderLayout.CENTER); + JButton save = new JButton("Save preferences"), signIn = new JButton("Sign in with Microsoft"), signOut = new JButton("Sign out"); + save.addActionListener(event -> save()); signIn.addActionListener(event -> signIn()); + signOut.addActionListener(event -> { if (cancelled != null) cancelled.set(true); service.signOut(); account.setText(service.accountLabel()); status.setText("Signed out; memory session cleared"); }); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); bottom.add(buttons(save, signIn, signOut), BorderLayout.NORTH); bottom.add(status, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); account.setText(service.accountLabel()); + } + private static void row(JPanel form, int row, String label, JComponent field) { + GridBagConstraints constraints = new GridBagConstraints(); constraints.gridx = 0; constraints.gridy = row; constraints.anchor = GridBagConstraints.WEST; constraints.insets = new Insets(5, 0, 5, 18); form.add(new JLabel(label), constraints); + constraints.gridx = 1; constraints.weightx = 1; constraints.fill = GridBagConstraints.HORIZONTAL; constraints.insets = new Insets(5, 0, 5, 0); form.add(field, constraints); + } + private boolean save() { + try { + java.util.Map paths = new java.util.LinkedHashMap<>(); int[] majors = {8, 17, 21, 25}; for (int i = 0; i < majors.length; i++) paths.put(majors[i], java[i].getText().trim()); + service.applyPreferences(offlineName.getText().trim(), offline.isSelected(), clientId.getText().trim(), template.getText().trim(), paths, Integer.parseInt(worldPort.getText().trim()), upnp.isSelected()); + account.setText(service.accountLabel()); status.setText("Preferences saved"); return true; + } catch (Exception exception) { JOptionPane.showMessageDialog(this, exception.getMessage(), "Invalid settings", JOptionPane.ERROR_MESSAGE); return false; } + } + private void signIn() { + if (cancelled != null && !cancelled.get()) { status.setText("A sign-in is already in progress"); return; } + if (!save()) return; + AtomicBoolean cancel = new AtomicBoolean(false); cancelled = cancel; + String authorizingClientId = service.settings.microsoftClientId; + long attempt = service.beginAuthentication(); authenticationAttempt = attempt; + work("Starting Microsoft device sign-in…", () -> { + MicrosoftOAuth oauth = new MicrosoftOAuth(authorizingClientId); + return new Object[]{oauth, oauth.beginDeviceAuthorization()}; + }, result -> { + MicrosoftOAuth oauth = (MicrosoftOAuth)result[0]; MicrosoftOAuth.DeviceAuthorization device = (MicrosoftOAuth.DeviceAuthorization)result[1]; + if (cancel.get() || !service.authenticationCurrent(attempt)) { oauth.clearSession(); cancel.set(true); return; } + JDialog dialog = new JDialog(SwingUtilities.getWindowAncestor(this), "Microsoft sign-in", Dialog.ModalityType.MODELESS); + JPanel content = new JPanel(new BorderLayout(12, 12)); content.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20)); + JTextArea instructions = new JTextArea("Open " + device.getVerificationUri() + "\nEnter code: " + device.getUserCode() + "\nExpires: " + device.getExpiresAt() + "\nComplete sign-in yourself in your browser.", 5, 48); instructions.setEditable(false); content.add(instructions, BorderLayout.CENTER); + JButton cancelButton = new JButton("Cancel sign-in"); cancelButton.addActionListener(event -> { cancel.set(true); dialog.dispose(); }); content.add(cancelButton, BorderLayout.SOUTH); + dialog.addWindowListener(new WindowAdapter() { public void windowClosing(WindowEvent event) { cancel.set(true); } }); dialog.setContentPane(content); dialog.pack(); dialog.setLocationRelativeTo(this); dialog.setVisible(true); + new SwingWorker() { + protected LaunchAccount doInBackground() throws Exception { return oauth.awaitMinecraftAccount(device, () -> cancel.get() || !service.authenticationCurrent(attempt)); } + protected void done() { + dialog.dispose(); + try { LaunchAccount loggedIn = get(); if (!cancel.get() && service.completeAuthenticationAndSave(attempt, loggedIn, oauth, authorizingClientId)) { offline.setSelected(false); account.setText(service.accountLabel()); status.setText("Signed in; credentials stay in memory"); } else { oauth.clearSession(); status.setText("Sign-in cancelled"); } } + catch (Exception exception) { oauth.clearSession(); if (!service.authenticationCurrent(attempt)) cancel.set(true); status.setText(cancel.get() ? "Sign-in cancelled" : "Sign-in failed"); if (!cancel.get()) JOptionPane.showMessageDialog(ClientSettingsPanel.this, exception.getCause() == null ? exception.getMessage() : exception.getCause().getMessage(), "Microsoft sign-in", JOptionPane.ERROR_MESSAGE); } + finally { cancel.set(true); } + } + }.execute(); + }, error -> { cancel.set(true); service.cancelAuthentication(attempt); }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/DashboardPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/DashboardPanel.java new file mode 100644 index 00000000..501c5a61 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/DashboardPanel.java @@ -0,0 +1,38 @@ +package com.osiris.autoplug.client.ui.client; + +import javax.swing.*; +import java.awt.*; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** Network, migration and preparation work never runs on Swing's event dispatch thread. */ +abstract class DashboardPanel extends JPanel { + protected final JLabel status = new JLabel("Ready"); + DashboardPanel() { super(new BorderLayout(12, 12)); setBorder(BorderFactory.createEmptyBorder(16, 16, 16, 16)); } + protected void work(String label, Callable action, Consumer done) { + work(label, action, done, ignored -> {}); + } + protected void work(String label, Callable action, Consumer done, Consumer failed) { + status.setText(label); + new SwingWorker() { + protected T doInBackground() throws Exception { return action.call(); } + protected void done() { + try { T result = get(); status.setText("Ready"); done.accept(result); } + catch (Exception exception) { + Throwable cause = exception.getCause() == null ? exception : exception.getCause(); + failed.accept(cause); + status.setText("Action failed"); + JOptionPane.showMessageDialog(DashboardPanel.this, cause.getMessage(), "AutoPlug", JOptionPane.ERROR_MESSAGE); + } + } + }.execute(); + } + protected boolean confirm(String message) { + if (SwingUtilities.isEventDispatchThread()) return JOptionPane.showConfirmDialog(this, message, "Review changes", JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION; + final boolean[] answer = {false}; + try { SwingUtilities.invokeAndWait(() -> answer[0] = confirm(message)); } + catch (Exception exception) { throw new IllegalStateException("Could not request confirmation", exception); } + return answer[0]; + } + protected static JPanel buttons(JButton... buttons) { JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); for (JButton button : buttons) panel.add(button); return panel; } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/ProfilesPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/ProfilesPanel.java new file mode 100644 index 00000000..32cafae1 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/ProfilesPanel.java @@ -0,0 +1,72 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.clientapp.NativeClientService; +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.tasks.updater.mods.TaskModsUpdater; +import com.osiris.autoplug.client.tasks.updater.plugins.TaskPluginsUpdater; +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.util.*; +import java.util.List; + +public final class ProfilesPanel extends DashboardPanel { + private final NativeClientService service; + private final ProfileUpdater updater; + private final DefaultTableModel model = new DefaultTableModel(new String[]{"Profile", "Type", "Minecraft", "Loader", "Template", "Directory"}, 0) { public boolean isCellEditable(int row, int column) { return false; } }; + private final JTable table = new JTable(model); + private List profiles = new ArrayList<>(); + public ProfilesPanel(NativeClientService service) { + this.service = service; updater = new ProfileUpdater(service.profiles.getCache()); + table.setRowHeight(32); table.setAutoCreateRowSorter(true); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + JScrollPane scroll = new JScrollPane(table); scroll.setColumnHeaderView(table.getTableHeader()); add(scroll, BorderLayout.CENTER); + JButton create = new JButton("Create"), clone = new JButton("Clone / migrate"), remove = new JButton("Delete"), template = new JButton("Toggle template"), check = new JButton("Check updates"), update = new JButton("Apply updates"), launch = new JButton("Launch client"), refresh = new JButton("Refresh"), activate = new JButton("Use for default updates"); + create.addActionListener(event -> create()); clone.addActionListener(event -> cloneProfile()); + remove.addActionListener(event -> { Profile profile = selected(); if (profile != null && confirm("Delete profile '" + profile.id + "' and all its local files? Shared cached artifacts are retained.")) work("Deleting profile…", () -> { service.profiles.delete(profile.id); return service.profiles.list(); }, this::showProfiles); }); + template.addActionListener(event -> { Profile profile = selected(); if (profile != null) work("Saving template selection…", () -> { service.profiles.setTemplate(profile.id, !profile.isTemplate); return service.profiles.list(); }, this::showProfiles); }); + check.addActionListener(event -> updates(false)); update.addActionListener(event -> updates(true)); + launch.addActionListener(event -> { Profile profile = selected(); if (profile != null) work("Preparing Minecraft…", () -> service.launch(profile.id, null), process -> status.setText("Minecraft process started")); }); + refresh.addActionListener(event -> load()); + activate.addActionListener(event -> { Profile profile = selected(); if (profile != null) work("Selecting default update profile…", () -> service.profiles.setActive(profile.id), active -> status.setText("Default " + active.type + " updates use " + active.id)); }); + JPanel top = new JPanel(new BorderLayout(0, 8)); top.add(new JLabel("Isolated client/server mods and server plugins • Shared cached JARs"), BorderLayout.NORTH); top.add(buttons(create, clone, template, remove, refresh), BorderLayout.CENTER); add(top, BorderLayout.NORTH); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); bottom.add(buttons(check, update, launch, activate), BorderLayout.NORTH); bottom.add(status, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); + try { showProfiles(service.profiles.list()); } catch (Exception exception) { status.setText("Could not read profiles: " + exception.getMessage()); } + } + private Profile selected() { int row = table.getSelectedRow(); if (row < 0) { status.setText("Select a profile first"); return null; } return profiles.get(table.convertRowIndexToModel(row)); } + private void load() { work("Loading profiles…", () -> service.profiles.list(), this::showProfiles); } + private void showProfiles(List list) { profiles = list; model.setRowCount(0); for (Profile profile : list) model.addRow(new Object[]{profile.id, profile.type, profile.gameVersion, profile.loader, profile.isTemplate ? "Yes" : "No", profile.directoryPath}); } + private ProfileUpdater.Plan check(Profile profile) throws Exception { + return profile.type == Profile.Type.PLUGINS ? TaskPluginsUpdater.processProfile(profile, updater, false, false, ignored -> {}) : TaskModsUpdater.processProfile(profile, updater, false, false, ignored -> {}); + } + private void updates(boolean apply) { + Profile profile = selected(); if (profile == null) return; + work("Resolving compatible releases…", () -> check(profile), plan -> { + if (!apply) { JOptionPane.showMessageDialog(this, new JScrollPane(new JTextArea(plan.summary(), 18, 65)), "Profile update plan", JOptionPane.INFORMATION_MESSAGE); return; } + if (!plan.errors.isEmpty()) { JOptionPane.showMessageDialog(this, plan.summary(), "Cannot apply updates", JOptionPane.ERROR_MESSAGE); return; } + if (confirm(plan.summary() + "\nApply this plan? Incompatible files listed as DISABLE become .jar.disabled.")) work("Applying checked profile updates…", () -> { updater.apply(plan, !plan.disabled().isEmpty()); return service.profiles.list(); }, this::showProfiles); + }); + } + private void create() { + JTextField id = new JTextField(24), version = new JTextField("1.20.4", 24); + JComboBox type = new JComboBox<>(Profile.Type.values()); + JComboBox loader = new JComboBox<>(new String[]{"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE"}); + type.addActionListener(event -> { loader.removeAllItems(); for (String value : type.getSelectedItem() == Profile.Type.PLUGINS ? new String[]{"SPIGOT", "PAPER", "PURPUR", "BUKKIT"} : new String[]{"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE"}) loader.addItem(value); }); + JCheckBox template = new JCheckBox("Use as a base template"); JPanel form = new JPanel(new GridLayout(0, 1, 0, 6)); + form.add(new JLabel("Unique profile ID")); form.add(id); form.add(new JLabel("Type")); form.add(type); form.add(new JLabel("Minecraft version")); form.add(version); form.add(new JLabel("Loader")); form.add(loader); form.add(template); + if (JOptionPane.showConfirmDialog(this, form, "Create profile", JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION) { + String name = id.getText(), target = version.getText(), selectedLoader = (String)loader.getSelectedItem(); Profile.Type selectedType = (Profile.Type)type.getSelectedItem(); boolean isTemplate = template.isSelected(); + work("Creating isolated profile…", () -> { service.profiles.create(name, selectedType, target, selectedLoader, isTemplate); return service.profiles.list(); }, this::showProfiles); + } + } + private void cloneProfile() { + Profile source = selected(); if (source == null) return; + String version = JOptionPane.showInputDialog(this, "Target Minecraft version", source.gameVersion); if (version == null) return; + work("Cloning and resolving compatible releases…", () -> { + Profile cloned = service.profiles.clone(source.id, version); + try { ProfileUpdater.Plan plan = check(cloned); if (!plan.errors.isEmpty()) throw new IllegalStateException(plan.summary()); + if (!confirm(plan.summary() + "\nComplete migration? Incompatible files listed as DISABLE become .jar.disabled.")) { service.profiles.delete(cloned.id); return service.profiles.list(); } + updater.apply(plan, !plan.disabled().isEmpty()); return service.profiles.list(); + } catch (Exception failure) { try { service.profiles.delete(cloned.id); } catch (Exception cleanup) { failure.addSuppressed(cleanup); } throw failure; } + }, this::showProfiles); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/ServerBrowserPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/ServerBrowserPanel.java new file mode 100644 index 00000000..d6a096fd --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/ServerBrowserPanel.java @@ -0,0 +1,80 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.clientapp.NativeClientService; +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.nio.file.Path; +import java.util.*; +import java.util.List; + +public final class ServerBrowserPanel extends DashboardPanel { + private final NativeClientService service; + private final DefaultTableModel model = new DefaultTableModel(new String[]{"Server", "Address", "Version", "Players", "Latency", "MOTD", "Local loader"}, 0) { + public boolean isCellEditable(int row, int column) { return false; } + }; + private final JTable table = new JTable(model); + private List servers = new ArrayList<>(); + public ServerBrowserPanel(NativeClientService service) { + this.service = service; + table.setRowHeight(32); table.setAutoCreateRowSorter(true); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + table.getAccessibleContext().setAccessibleName("Saved Minecraft servers"); + JScrollPane scroll = new JScrollPane(table); scroll.setColumnHeaderView(table.getTableHeader()); add(scroll, BorderLayout.CENTER); + table.setDefaultRenderer(Object.class, new javax.swing.table.DefaultTableCellRenderer() { + public Component getTableCellRendererComponent(JTable owner, Object value, boolean selected, boolean focus, int row, int column) { + putClientProperty("html.disable", Boolean.TRUE); + return super.getTableCellRendererComponent(owner, value, selected, focus, row, column); + } + }); + JButton add = new JButton("Add server"), edit = new JButton("Edit"), remove = new JButton("Remove"), refresh = new JButton("Refresh status"), play = new JButton("Play"), importBtn = new JButton("Import Minecraft list"), export = new JButton("Export to Minecraft"); + add.addActionListener(event -> editServer(null)); edit.addActionListener(event -> { SavedServer selected = selected(); if (selected != null) editServer(selected); }); + remove.addActionListener(event -> { SavedServer selected = selected(); if (selected != null && confirm("Remove " + selected.name + " from AutoPlug's saved servers?")) work("Saving servers…", () -> service.servers.remove(selected), this::showServers); }); + refresh.addActionListener(event -> refresh()); + play.addActionListener(event -> { SavedServer selected = selected(); if (selected != null) work("Resolving profile and preparing Minecraft…", () -> { + ServerStatus ping = ServerStatus.ping(selected); if (!ping.online) throw new IllegalStateException("Server is unavailable: " + ping.failure); + return service.autoPlay(ping, this::confirm); + }, process -> status.setText(process == null ? "Play cancelled" : "Minecraft process started")); }); + importBtn.addActionListener(event -> { JFileChooser chooser = chooser(); if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) work("Importing servers…", () -> { int imported = service.servers.importVanilla(chooser.getSelectedFile().toPath()); return imported; }, count -> { status.setText("Imported " + count + " servers"); load(); }); }); + export.addActionListener(event -> { JFileChooser chooser = chooser(); if (chooser.showSaveDialog(this) == JFileChooser.APPROVE_OPTION && confirm("Replace the chosen Minecraft servers.dat with this list? A backup is created if it exists. Retained icons and other tags are preserved.")) work("Exporting servers…", () -> service.servers.exportVanilla(chooser.getSelectedFile().toPath()), backup -> status.setText(backup == null ? "Exported list" : "Exported; backup: " + backup.getFileName())); }); + JPanel top = new JPanel(new BorderLayout(0, 8)); + top.add(new JLabel("Saved servers • Play matches a local profile or clones and migrates a template"), BorderLayout.NORTH); + top.add(buttons(add, edit, remove, refresh, play), BorderLayout.CENTER); + add(top, BorderLayout.NORTH); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); bottom.add(buttons(importBtn, export), BorderLayout.NORTH); bottom.add(status, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); + // No automatic network access on construction: server status is user initiated. + try { showServers(service.servers.list()); } catch (Exception exception) { status.setText("Could not read servers: " + exception.getMessage()); } + } + private JFileChooser chooser() { JFileChooser chooser = new JFileChooser(); chooser.setSelectedFile(ServerCatalog.vanillaPath().toFile()); return chooser; } + private SavedServer selected() { int row = table.getSelectedRow(); if (row < 0) { status.setText("Select a server first"); return null; } return servers.get(table.convertRowIndexToModel(row)); } + private void load() { work("Loading servers…", () -> service.servers.list(), this::showServers); } + /** Tray opening imports saved data without initiating status pings or changing the vanilla file. */ + public void importDefaultIfPresent() { + Path vanilla = ServerCatalog.vanillaPath(); + if (java.nio.file.Files.isRegularFile(vanilla)) work("Importing your Minecraft server list…", () -> { service.servers.importVanilla(vanilla); return service.servers.list(); }, this::showServers); + } + private void showServers(List list) { servers = list; model.setRowCount(0); for (SavedServer server : list) model.addRow(new Object[]{server.name, server.address, "Not checked", "—", "—", "—", server.loaderHint}); } + private void refresh() { + work("Reloading saved servers…", () -> service.servers.list(), latest -> { showServers(latest); refreshStatuses(new ArrayList<>(latest)); }); + } + private void refreshStatuses(List snapshot) { + work("Checking server status…", () -> { List result = new ArrayList<>(); for (SavedServer server : snapshot) result.add(ServerStatus.ping(server)); return result; }, statuses -> { + if (!servers.equals(snapshot)) return; + for (int row = 0; row < statuses.size(); row++) { + ServerStatus ping = statuses.get(row); + model.setValueAt(ping.online ? ping.version + " (" + ping.protocol + ")" : ping.failure, row, 2); + model.setValueAt(ping.online ? ping.players + "/" + ping.capacity : "—", row, 3); + model.setValueAt(ping.online ? ping.latency + " ms" : "—", row, 4); + model.setValueAt(ping.online ? ping.motd : "Unavailable", row, 5); + } + }); + } + private void editServer(SavedServer previous) { + JTextField name = new JTextField(previous == null ? "" : previous.name, 24), address = new JTextField(previous == null ? "" : previous.address.toString(), 24); + JComboBox loader = new JComboBox<>(new String[]{"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE"}); loader.setSelectedItem(previous == null ? "VANILLA" : previous.loaderHint); + JPanel form = new JPanel(new GridLayout(0, 1, 0, 6)); form.add(new JLabel("Name")); form.add(name); form.add(new JLabel("Address (host:port or [IPv6]:port)")); form.add(address); form.add(new JLabel("Local loader choice (server pings do not report mods)")); form.add(loader); + if (JOptionPane.showConfirmDialog(this, form, previous == null ? "Add server" : "Edit server", JOptionPane.OK_CANCEL_OPTION) != JOptionPane.OK_OPTION) return; + try { SavedServer server = new SavedServer(name.getText(), ServerAddress.parse(address.getText()), (String)loader.getSelectedItem()); work("Saving server…", () -> service.servers.edit(previous, server), this::showServers); } + catch (Exception exception) { JOptionPane.showMessageDialog(this, exception.getMessage(), "Invalid server", JOptionPane.ERROR_MESSAGE); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/ServerManagerPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/ServerManagerPanel.java new file mode 100644 index 00000000..7a2e6fbd --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/ServerManagerPanel.java @@ -0,0 +1,38 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.console.Commands; +import com.osiris.autoplug.client.Server; +import com.osiris.autoplug.client.ui.ServerPanel; +import com.osiris.jlib.logger.AL; +import com.osiris.jlib.logger.MessageFormatter; +import javax.swing.*; +import java.awt.*; + +/** Keeps existing task/configuration panels and server commands in the combined dashboard. */ +public final class ServerManagerPanel extends DashboardPanel { + private Runnable unsubscribe = () -> {}; + public ServerManagerPanel() throws Exception { + JTextArea console = new JTextArea(20, 80); console.setEditable(false); console.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + listen(AL.actionsOnMessageEvent, message -> { + String text = MessageFormatter.formatForFile(message); + SwingUtilities.invokeLater(() -> { + console.append(text + "\n"); + if (console.getDocument().getLength() > 128 * 1024) console.replaceRange("", 0, console.getDocument().getLength() - 96 * 1024); + console.setCaretPosition(console.getDocument().getLength()); + }); + }); + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Console", new JScrollPane(console)); tabs.addTab("Existing updates and settings", new ServerPanel(tabs)); add(tabs, BorderLayout.CENTER); + String[] names = {"Start", "Restart", "Stop", "Backup", "Run tasks"}, commands = {".start", ".restart", ".stop", ".backup", ".run tasks"}; + JButton[] controls = new JButton[names.length]; + for (int i = 0; i < controls.length; i++) { final String command = commands[i]; controls[i] = new JButton(names[i]); controls[i].addActionListener(event -> work("Running " + command + "…", () -> Commands.execute(command), handled -> status.setText("Command dispatched; see console"))); } + add(buttons(controls), BorderLayout.NORTH); + JTextField input = new JTextField(); input.setToolTipText("Server command or AutoPlug command (for example .check plugins)"); + JButton send = new JButton("Execute command"); Runnable execute = () -> { String command = input.getText().trim(); if (command.isEmpty()) return; input.setText(""); work("Executing command…", () -> { if (!Commands.execute(command)) Server.submitCommand(command); return true; }, handled -> status.setText("Command dispatched; see console")); }; + input.addActionListener(event -> execute.run()); send.addActionListener(event -> execute.run()); + JPanel commandRow = new JPanel(new BorderLayout(8, 0)); commandRow.add(input, BorderLayout.CENTER); commandRow.add(send, BorderLayout.EAST); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); bottom.add(commandRow, BorderLayout.NORTH); bottom.add(status, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); + } + private void listen(java.util.List> listeners, com.osiris.jlib.events.MessageEvent listener) { listeners.add(listener); unsubscribe = () -> listeners.remove(listener); } + public void release() { unsubscribe.run(); unsubscribe = () -> {}; } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/client/VirtualWorldsPanel.java b/src/main/java/com/osiris/autoplug/client/ui/client/VirtualWorldsPanel.java new file mode 100644 index 00000000..56416096 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/client/VirtualWorldsPanel.java @@ -0,0 +1,77 @@ +package com.osiris.autoplug.client.ui.client; + +import com.osiris.autoplug.client.clientapp.*; +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.worlds.*; +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.awt.image.BufferedImage; +import java.util.*; +import java.util.List; + +public final class VirtualWorldsPanel extends DashboardPanel { + private final NativeClientService service; + private final WorldClientContext context; + private final DefaultTableModel model = new DefaultTableModel(new String[]{"", "World", "Minecraft", "Server profile", "Client profile", "State"}, 0) { + public boolean isCellEditable(int row, int column) { return false; } + public Class getColumnClass(int column) { return column == 0 ? Icon.class : Object.class; } + }; + private final JTable table = new JTable(model); + private List worlds = new ArrayList<>(); + private final javax.swing.Timer refresh; + public VirtualWorldsPanel(NativeClientService service) throws java.io.IOException { + this.service = service; context = WorldClientContext.of(service); + table.setRowHeight(48); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setAutoCreateRowSorter(true); + table.getColumnModel().getColumn(0).setMaxWidth(48); table.getAccessibleContext().setAccessibleName("Virtual singleplayer worlds"); + table.setDefaultRenderer(Object.class, new javax.swing.table.DefaultTableCellRenderer() { public Component getTableCellRendererComponent(JTable owner, Object value, boolean selected, boolean focus, int row, int column) { putClientProperty("html.disable", Boolean.TRUE); return super.getTableCellRendererComponent(owner, value, selected, focus, row, column); } }); + JScrollPane scroll = new JScrollPane(table); scroll.setColumnHeaderView(table.getTableHeader()); add(scroll, BorderLayout.CENTER); + JButton create = new JButton("Create world"), client = new JButton("Choose client"), jar = new JButton("Server JAR"), eula = new JButton("Review EULA"), remove = new JButton("Delete"); + create.addActionListener(event -> create()); client.addActionListener(event -> chooseClient()); jar.addActionListener(event -> chooseJar()); eula.addActionListener(event -> eula()); remove.addActionListener(event -> remove()); + JPanel top = new JPanel(new BorderLayout(0, 8)); top.add(new JLabel("Play server plugins or server mods in an isolated local world"), BorderLayout.NORTH); top.add(buttons(create, client, jar, eula, remove), BorderLayout.CENTER); add(top, BorderLayout.NORTH); + JButton play = new JButton("Play locally"), share = new JButton("Play & share"), copy = new JButton("Copy join address"), stop = new JButton("Stop world"), logs = new JButton("Server output"); + play.addActionListener(event -> launch(false)); share.addActionListener(event -> launch(true)); copy.addActionListener(event -> copyAddress()); + stop.addActionListener(event -> { WorldDefinition world = selected(); if (world != null) work("Stopping world…", () -> { VirtualWorldService.RunningWorld running = context.lifecycle.running(world.id); context.lifecycle.stop(world.id); return running; }, running -> { reload(); status.setText(running != null && running.cleanupFailure() != null ? "World stopped • " + running.cleanupFailure() : "World stopped"); }); }); + logs.addActionListener(event -> { WorldDefinition world = selected(); VirtualWorldService.RunningWorld running = world == null ? null : context.lifecycle.running(world.id); if (running != null) { JTextArea output = new JTextArea(String.join("\n", running.recentOutput()), 20, 70); output.setEditable(false); JOptionPane.showMessageDialog(this, new JScrollPane(output), "Local server output", JOptionPane.PLAIN_MESSAGE); } }); + JPanel bottom = new JPanel(new BorderLayout(0, 8)); bottom.add(buttons(play, share, copy, stop, logs), BorderLayout.NORTH); bottom.add(status, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); + showWorlds(context.worlds.list()); refresh = new javax.swing.Timer(1500, event -> { if (isShowing()) reload(); }); refresh.start(); + } + public void release() { refresh.stop(); } + private WorldDefinition selected() { int row = table.getSelectedRow(); if (row < 0) { status.setText("Select a world first"); return null; } return worlds.get(table.convertRowIndexToModel(row)); } + private void reload() { try { showWorlds(context.worlds.list()); } catch (Exception error) { status.setText("Could not read worlds: " + error.getMessage()); } } + private void showWorlds(List list) { + String selected = table.getSelectedRow() >= 0 && table.getSelectedRow() < worlds.size() ? worlds.get(table.convertRowIndexToModel(table.getSelectedRow())).id : null; + worlds = list; model.setRowCount(0); + for (WorldDefinition world : worlds) { VirtualWorldService.RunningWorld running = context.lifecycle.running(world.id); String state = running != null && !running.isClosed() ? "Playing" : world.acceptedEula ? "Ready" : "Review EULA"; if (running != null && running.shareFailure() != null) state = "Playing; share failed"; model.addRow(new Object[]{thumbnail(world), world.name, world.gameVersion, world.profileId, world.clientProfileId == null ? world.profileType == Profile.Type.MODS_SERVER ? "Choose matching client" : "Automatic vanilla" : world.clientProfileId, state}); } + if (selected != null) for (int row = 0; row < worlds.size(); row++) if (worlds.get(row).id.equals(selected)) { int view = table.convertRowIndexToView(row); table.setRowSelectionInterval(view, view); break; } + } + private static Icon thumbnail(WorldDefinition world) { + BufferedImage image = new BufferedImage(32, 32, BufferedImage.TYPE_INT_RGB); Graphics2D draw = image.createGraphics(); draw.setColor(new Color(106, 173, 225)); draw.fillRect(0, 0, 32, 32); draw.setColor(new Color(91, 154, 65)); draw.fillRect(0, 13, 32, 7); draw.setColor(new Color(125, 91, 52)); draw.fillRect(0, 20, 32, 12); draw.setColor(Color.WHITE); draw.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 15)); draw.drawString(world.name.substring(0, 1).toUpperCase(Locale.ROOT), 10, 26); draw.dispose(); return new ImageIcon(image); + } + private void create() { + try { List choices = new ArrayList<>(); for (Profile profile : service.profiles.list()) if (profile.type != Profile.Type.MODS) choices.add(profile); if (choices.isEmpty()) { status.setText("Create a PLUGINS or MODS_SERVER profile first"); return; } + Profile profile = (Profile)JOptionPane.showInputDialog(this, "Server plugin/mod profile", "Create local world", JOptionPane.PLAIN_MESSAGE, null, choices.toArray(), choices.get(0)); if (profile == null) return; + String name = JOptionPane.showInputDialog(this, "World name"); if (name != null) work("Creating world…", () -> { context.worlds.create(name, profile.id); return context.worlds.list(); }, this::showWorlds); + } catch (Exception failure) { status.setText(failure.getMessage()); } + } + private void chooseClient() { + WorldDefinition world = selected(); if (world == null) return; + try { List clients = new ArrayList<>(); for (Profile profile : service.profiles.list()) if (profile.type == Profile.Type.MODS && profile.gameVersion.equals(world.gameVersion) && (world.profileType != Profile.Type.MODS_SERVER || profile.loader.equals(world.loader))) clients.add(profile); if (clients.isEmpty()) { status.setText("Create a matching MODS client profile first"); return; } + Profile profile = (Profile)JOptionPane.showInputDialog(this, "Matching client profile (server-only mods are never copied automatically)", "World client", JOptionPane.PLAIN_MESSAGE, null, clients.toArray(), clients.get(0)); if (profile != null) work("Saving client profile…", () -> { context.worlds.setClientProfile(world.id, profile.id); return context.worlds.list(); }, this::showWorlds); + } catch (Exception failure) { status.setText(failure.getMessage()); } + } + private void chooseJar() { WorldDefinition world = selected(); if (world == null) return; JFileChooser chooser = new JFileChooser(); if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) work("Saving server executable…", () -> { context.worlds.setServerExecutable(world.id, chooser.getSelectedFile().toPath()); return context.worlds.list(); }, this::showWorlds); } + private void eula() { WorldDefinition world = selected(); if (world != null && confirm("Review https://www.minecraft.net/en-us/eula before continuing.\nDo you accept the Minecraft EULA for this local world? This acceptance is saved only for this world.")) work("Saving your EULA choice…", () -> { context.worlds.acceptEula(world.id, true); return context.worlds.list(); }, this::showWorlds); } + private void remove() { WorldDefinition world = selected(); if (world == null) return; if (context.lifecycle.isRunning(world.id)) { status.setText("Stop this world before deleting its save data"); return; } if (confirm("Permanently delete local world '" + world.name + "' and all its save data? The source profile and shared cache are retained.")) work("Deleting world…", () -> { context.worlds.delete(world.id); return context.worlds.list(); }, this::showWorlds); } + private void launch(boolean share) { + WorldDefinition world = selected(); if (world == null) return; + if (share && !confirm("Play and share this world with friends?\nThe server will accept network connections and request a temporary TCP mapping from your router. Online Microsoft authentication is required. The mapping is removed when the world stops. An already running local-only world will be stopped and restarted.")) return; + work(share ? "Preparing shared world…" : "Preparing local world…", () -> { + VirtualWorldService.RunningWorld running = context.lifecycle.running(world.id); + if (running != null && !running.isClosed()) { if (!share) return running; if (running.sharingEnabled) { context.lifecycle.share(world.id); return running; } running.close(); } + return context.lifecycle.launch(world.id, share); + }, running -> { reload(); status.setText(running.shareFailure() == null ? "World started • " + running.address : "Playing locally • " + running.shareFailure()); }); + } + private void copyAddress() { WorldDefinition world = selected(); if (world == null) return; try { VirtualWorldService.RunningWorld running = context.lifecycle.running(world.id); String address = running == null ? null : running.publicAddress(); if (address == null) throw new IllegalStateException("Use Play & share first; no public mapping is active"); Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(address), null); status.setText("Copied " + address); } catch (Exception failure) { status.setText(failure.getMessage()); } } +} diff --git a/src/main/java/com/osiris/autoplug/client/utils/MineStat.java b/src/main/java/com/osiris/autoplug/client/utils/MineStat.java index 8e23edf9..570369c5 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/MineStat.java +++ b/src/main/java/com/osiris/autoplug/client/utils/MineStat.java @@ -55,6 +55,7 @@ public class MineStat { * Minecraft version the server is running */ private String version; + private int protocol = -1; /** * Current number of players on the server */ @@ -178,26 +179,30 @@ public String stripMotdFormatting(String motd) { } public String stripMotdFormatting(JsonObject motd) { - StringBuilder strippedMotd = new StringBuilder(); - - if (motd.isJsonPrimitive()) { - return motd.getAsString(); - } + return stripMotdFormatting((JsonElement) motd); + } - JsonObject motdObj = motd.getAsJsonObject(); - if (motdObj.has("text")) { - strippedMotd.append(motdObj.get("text").getAsString()); - } + public String stripMotdFormatting(JsonElement element) { + return stripMotdFormatting(flattenMotd(element, 0)); + } - if (motdObj.has("extra") && motdObj.get("extra").isJsonArray()) { - for (JsonElement extraElem : motdObj.get("extra").getAsJsonArray()) { - strippedMotd.append(stripMotdFormatting(extraElem.getAsJsonObject())); - } + private String flattenMotd(JsonElement element, int depth) { + if (element == null || element.isJsonNull() || depth > 64) return ""; + if (element.isJsonPrimitive()) return element.getAsString(); + StringBuilder text = new StringBuilder(); + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) text.append(flattenMotd(child, depth + 1)); + } else { + JsonObject object = element.getAsJsonObject(); + if (object.has("text")) text.append(flattenMotd(object.get("text"), depth + 1)); + else if (object.has("translate")) text.append(flattenMotd(object.get("translate"), depth + 1)); + if (object.has("extra")) text.append(flattenMotd(object.get("extra"), depth + 1)); } - - return strippedMotd.toString(); + return text.toString(); } + public int getProtocol() { return protocol; } + public String getVersion() { return version; } @@ -519,50 +524,54 @@ public boolean isDataValid() { * 'description': {'text': 'A Minecraft Server'}} */ public Retval jsonRequest(String address, int port, int timeout) { - try { - String[] serverData = null; - byte[] rawServerData = null; - Socket clientSocket = new Socket(); - long startTime = System.currentTimeMillis(); - clientSocket.connect(new InetSocketAddress(getAddress(), getPort()), getTimeout()); - setLatency(System.currentTimeMillis() - startTime); + serverUp = false; + protocol = -1; + try (Socket clientSocket = new Socket()) { + if (timeout <= 0 || address == null || address.isEmpty() || port < 1 || port > 65535) return Retval.UNKNOWN; + long startTime = System.nanoTime(); + clientSocket.connect(new InetSocketAddress(address, port), timeout); + clientSocket.setSoTimeout(timeout); ByteArrayOutputStream baos = new ByteArrayOutputStream(); DataOutputStream payload = new DataOutputStream(baos); DataOutputStream dos = new DataOutputStream(clientSocket.getOutputStream()); DataInputStream dis = new DataInputStream(new BufferedInputStream(clientSocket.getInputStream())); payload.writeByte(0x00); // handshake packet sendVarInt(payload, 0x00); // protocol version - sendVarInt(payload, address.length()); // packed remote address length as varint - payload.writeBytes(address); // remote address as string + byte[] addressBytes = address.getBytes(StandardCharsets.UTF_8); + if (addressBytes.length > 1020) return Retval.UNKNOWN; + sendVarInt(payload, addressBytes.length); + payload.write(addressBytes); payload.writeShort(port); // remote port as short sendVarInt(payload, 0x01); // state packet sendVarInt(dos, baos.size()); // payload size as varint dos.write(baos.toByteArray()); // send payload dos.writeByte(0x01); // size dos.writeByte(0x00); // ping packet - int totalLength = recvVarInt(dis); // total response size - int packetID = recvVarInt(dis); // packet ID - int jsonLength = recvVarInt(dis); // JSON response size + dos.flush(); + int totalLength = readStatusVarInt(dis); + if (totalLength < 3 || totalLength > 1024 * 1024) return Retval.UNKNOWN; + byte[] packetBytes = new byte[totalLength]; + dis.readFully(packetBytes); + DataInputStream packet = new DataInputStream(new ByteArrayInputStream(packetBytes)); + int packetID = readStatusVarInt(packet); + int jsonLength = readStatusVarInt(packet); + if (packetID != 0 || jsonLength < 1 || jsonLength != packet.available()) return Retval.UNKNOWN; byte[] rawData = new byte[jsonLength]; // storage for JSON data - - dis.readFully(rawData); // fill byte array with JSON data - - // Close sockets - if (!clientSocket.isClosed()) { - clientSocket.close(); - } + packet.readFully(rawData); + setLatency((System.nanoTime() - startTime) / 1_000_000L); // Populate object from JSON data - JsonObject jobj = new Gson().fromJson(new String(rawData), JsonObject.class); + JsonObject jobj = new Gson().fromJson(new String(rawData, StandardCharsets.UTF_8), JsonObject.class); setMotd(jobj.get("description").toString()); - setStrippedMotd(stripMotdFormatting(jobj.get("description").getAsJsonObject())); + setStrippedMotd(stripMotdFormatting(jobj.get("description"))); setVersion(jobj.get("version").getAsJsonObject().get("name").getAsString()); + protocol = jobj.getAsJsonObject("version").get("protocol").getAsInt(); setCurrentPlayers(jobj.get("players").getAsJsonObject().get("online").getAsInt()); setMaximumPlayers(jobj.get("players").getAsJsonObject().get("max").getAsInt()); - serverUp = true; setRequestType("SLP 1.7 (JSON)"); - if (!isDataValid()) + if (!isDataValid() || protocol < 0) return Retval.UNKNOWN; + serverUp = true; } catch (ConnectException ce) { return Retval.CONNFAIL; } catch (SocketException se) { @@ -579,6 +588,18 @@ public Retval jsonRequest(String address, int port, int timeout) { return Retval.SUCCESS; } + // Unlike the historical public helper, preserve I/O exceptions so a stalled status read reports TIMEOUT. + private int readStatusVarInt(DataInputStream input) throws IOException { + int result = 0; + for (int position = 0; position < 5; position++) { + int next = input.readUnsignedByte(); + if (position == 4 && (next & 0xf0) != 0) throw new IOException("Status VarInt overflow"); + result |= (next & 0x7f) << (position * 7); + if ((next & 0x80) == 0) return result; + } + throw new IOException("Status VarInt is too long"); + } + public enum Retval { SUCCESS(0), CONNFAIL(-1), TIMEOUT(-2), UNKNOWN(-3); @@ -606,4 +627,4 @@ public int getRequest() { return request; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java b/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java index f02f3d5d..256ccf51 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java +++ b/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java @@ -48,8 +48,7 @@ public String getInstalledVersion() throws NotLoadedException, YamlReaderExcepti public String getInstalledVersion(File serverJar) { String version = null; if (serverJar == null || !serverJar.exists()) return null; - try { - ZipFile zipFile = new ZipFile(serverJar); + try (ZipFile zipFile = new ZipFile(serverJar)) { Enumeration entries = zipFile.entries(); boolean found = false; while (entries.hasMoreElements()) { diff --git a/src/main/java/com/osiris/autoplug/client/worlds/PROTOCOL.md b/src/main/java/com/osiris/autoplug/client/worlds/PROTOCOL.md new file mode 100644 index 00000000..27877896 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/PROTOCOL.md @@ -0,0 +1,36 @@ +# Virtual worlds and dedicated server ownership + +All source in this package is original MIT code. It imports neither HMCL nor another launcher's implementation. The native Minecraft client launcher remains the shared UI/CLI client execution path. + +`WorldStore` saves definitions in `~/.autoplug/worlds/definitions/.properties` and independent server installations/saves in `~/.autoplug/worlds/instances//`. Stable OS lock files live in `worlds/locks/` outside save data; deletion retains that lock until save and metadata removal finish, including on Windows. Launch acquires the same lock and re-reads metadata before preparing. The small lock files are retained so deleting/recreating a path cannot create competing lock inodes. A world records its source server profile, Minecraft version, loader, optional client profile and optional explicitly supplied executable JAR. Creation copies `plugins/` or `mods/`, `config/` and `defaultconfigs/`; immutable JAR assets use the existing content-addressed cache. Mutable configs are separate copies. Credentials and launcher account files are not copied. The profile collection is a creation-time snapshot: later profile updates do not overwrite an existing world's configs or save. The selected source profile must still exist and retain its recorded type/version/loader when playing. Deleted-source worlds remain visible and can still be deleted or inspected. + +Client `MODS` profiles, plugin `PLUGINS` profiles and server `MODS_SERVER` profiles have separate active defaults. Both `mods-server` and `mods_server` are CLI aliases. Server mods use the existing profile updater, Modrinth exact-file lookup and configured CurseForge provider; migration retains the existing review/disabling transaction. Plugin worlds can generate an isolated vanilla client profile. Server mod worlds require an explicitly selected client `MODS` profile with the same game version and loader; copying server-only mods into the client would be unsafe. + +World creation and launch reject known contradictory type/loader pairs: plugin profiles cannot name the Fabric/Quilt/Forge/NeoForge mod loaders, and server mod profiles cannot name Paper/Purpur/Spigot/Bukkit plugin servers. This prevents a nominally successful server launch that silently ignores the entire selected asset directory. Explicitly named custom/hybrid loaders remain available with a matching supplied executable. + +An explicit acceptance action records the Minecraft EULA decision per world. Nothing downloads a server or writes `eula=true` during world creation, and neither the UI nor CLI may infer acceptance from owning a Minecraft account. Production launch checks the decision before preparing the server. Unit tests accept only a temporary original synthetic fixture world and never accept the owner's EULA or start Minecraft. + +The production server installer uses the same official provider protocols as AutoPlug's existing server updater and the existing `LauncherDownloads` cache. It does not swap process-global server executable paths, updater YAML, `Server` state, `user.dir`, or the owner's existing installation: + +| Server loader | Automatic source | Integrity and execution | +| --- | --- | --- | +| Paper | Stable build from `fill.papermc.io/v3/projects/paper/versions//builds` | Published SHA-256 and byte length; executable server JAR | +| Purpur | Successful version-specific build from `api.purpurmc.org/v2/purpur//latest` | Published MD5 over official HTTPS; pinned build download | +| Fabric | Stable version-compatible loader plus stable installer from `meta.fabricmc.net/v2/versions` | Official generated server-launcher HTTPS endpoint; it supplies no published checksum. Its bootstrap performs its normal server/library installation inside the world directory | +| Spigot and other server loaders | Explicitly supplied matching executable server JAR | Readable manifest `Main-Class`; existing `UtilsMinecraft` version detection rejects a detectable mismatch. JARs that do not expose a detectable game version rely on the user's explicit matching selection | + +Paper's download service requires an identifying User-Agent with a contact URL; this implementation supplies AutoPlug's repository URL. Stable builds are selected only within the requested game version; an absent release reports a clear error and never substitutes another game version. See [Paper's downloads service documentation](https://docs.papermc.io/misc/downloads-service/), the existing repository `PaperUpdater`, `PurpurUpdater` and `FabricUpdater` adapters, and the captured provider fixtures in `src/test/resources/virtual-worlds/`. + +Spigot BuildTools is rejected as an installer rather than misrepresented as a runnable server. Forge/NeoForge distributions that require installation, argument files or platform run scripts do not gain an automatic dedicated-server installation path from this change; a directly executable matching bootstrap/server JAR may be supplied. Client Forge/NeoForge support remains in the native launch engine. This distinction is displayed through the server preparation failure, not hidden behind a successful fixture. + +Java is selected using the exact game's official Mojang metadata and the existing `ManagedJavaRuntime`, honoring the public Java overrides in client settings. A chosen server executable is copied into the world directory, so independent worlds do not replace one another's executable. `server.properties` preserves unrelated local values but sets the managed save path, chosen port, status ping, RCON/query disabling and binding. A normal world binds to `127.0.0.1`; explicit sharing at launch binds on the host interfaces so a router can forward to it. Explicit client offline testing permits an unauthenticated server only while bound to loopback. Sharing requires online authentication. + +`VirtualWorldService` owns a per-world OS file lock, a real dedicated-server `ProcessBuilder`, a matching native client process and an injected UPnP mapping. Settings select either port `0` for an available port or a fixed preferred port, and explicitly allow or disallow UPnP. A configured occupied port reports an error rather than silently using another port. As with ordinary separate server processes there is a small bind race after releasing the temporary socket; bind/startup failures are cleaned up. Readiness reuses AutoPlug `MineStat` JSON status and checks the advertised Minecraft version. No production raw TCP Minecraft ping is introduced here. + +Only after readiness does the service request sharing and launch the client at `127.0.0.1:`. Sharing is an explicit launch action and is rejected before preparation when the UPnP preference is disabled. A UPnP failure retains local play and returns a clear `shareFailure()` notice with manual forwarding/tunnel/CGNAT guidance. A world launched on loopback must restart with Share enabled before forwarding can work. The mapping is injected, so lifecycle fixtures never discover or change the owner's router. The production mapper owns its finite lease and reports renewal/ownership failures through its join address. + +Server output is continuously drained with at most 80 retained lines of 2,048 bytes each; client pipes are drained without retaining account-related output. When either child exits, client startup fails, readiness expires, startup is cancelled, Stop is requested, or the application closes, the service terminates the owned client, sends `stop` to the server, waits a bounded grace period, then destroys/forcibly destroys remaining processes and observed descendants. It closes the mapping and file lock and removes the active lifecycle. Pending preparation is interrupted and cannot install a child process into a cancelled lifecycle without immediate cleanup. Cancelling a pending client preparation closes any already owned mapping immediately, even if that preparation has not returned; the OS lock stays held until preparation's final cleanup so deleting or restarting cannot race its remaining work. Shutdown waits up to ten seconds in total for pending startup operations to finish ownership cleanup; it waits on operation completion, not on the reusable UI/executor thread itself. Router cleanup errors remain available through `cleanupFailure()`; a finite router lease additionally bounds a mapping after abnormal host termination. Headless one-shot world commands must wait for their returned client process and then close the lifecycle before parent process exit. + +The tests include successful read-only production GET captures for Paper, Purpur, Fabric loaders and Fabric installers on 2026-10-03. `sources.json` records the exact URLs, times, response byte lengths, original response SHA-256, selected fixture SHA-256 and the explicit field/record reduction. Values in those fixtures are real API responses, not fabricated server metadata. Ordinary tests use those captures offline and an original synthetic server/client executable in real child JVMs. The synthetic server implements only enough status protocol to exercise the existing `MineStat`; it is not Minecraft. Tests cover preparation, direct connection, stream draining, graceful stop, mapping cleanup, readiness/server/client failure, startup cancellation, forced stop, preferred-port/UPnP settings and isolation. One-shot CLI bridge tests wait for the actual child client to exit and also fail the output consumer immediately after launch; both paths must clean up the server, client and mapping. + +No production server artifact was executed, no paid Minecraft account was used, no owner EULA was accepted and no real router mapping was attempted as contributor validation. A complete client-authenticated game join and public-world accessibility still require the maintainer's real game/router environment. The captured metadata and real fixture processes support the implementation without claiming that those distinct live outcomes occurred. diff --git a/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorldService.java b/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorldService.java new file mode 100644 index 00000000..646270d7 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorldService.java @@ -0,0 +1,286 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.clientapp.NativeClientService; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.utils.MineStat; +import java.io.*; +import java.net.*; +import java.nio.channels.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.function.IntSupplier; + +/** Owns both child processes and optional router mapping from startup until the client exits. */ +public final class VirtualWorldService implements AutoCloseable { + public interface ServerPreparer { ProcessBuilder prepare(WorldDefinition world, int port, boolean sharing) throws Exception; } + public interface ClientLauncher { Process launch(String profileId, ServerAddress address) throws Exception; } + public interface Sharing { Mapping map(int localPort) throws Exception; } + public interface Mapping extends AutoCloseable { String joinAddress(); @Override void close() throws Exception; } + public static final class RunningWorld implements AutoCloseable { + public final WorldDefinition world; + public final ServerAddress address; + public final boolean sharingEnabled; + private final VirtualWorldService owner; + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final CountDownLatch startupFinished = new CountDownLatch(1); + private final Deque output = new ArrayDeque<>(); + private volatile Process server, client; + private volatile Mapping mapping; + private volatile String shareFailure, cleanupFailure; + private volatile boolean launching = true, closed; + private volatile Thread launchThread; + private FileChannel lockChannel; + private FileLock lock; + private RunningWorld(VirtualWorldService owner, WorldDefinition world, int port, boolean sharing) { + this.owner = owner; this.world = world; address = ServerAddress.parse("127.0.0.1:" + port); sharingEnabled = sharing; + } + public Process serverProcess() { return server; } + public Process clientProcess() { return client; } + public String publicAddress() { Mapping current = mapping; return current == null ? null : current.joinAddress(); } + public String shareFailure() { return shareFailure; } + public String cleanupFailure() { return cleanupFailure; } + public boolean isClosed() { return closed; } + public List recentOutput() { synchronized (output) { return Collections.unmodifiableList(new ArrayList<>(output)); } } + private void addOutput(String line) { synchronized (output) { while (output.size() >= 80) output.removeFirst(); output.addLast(line); } } + @Override public void close() { owner.stop(world.id); } + } + private final WorldStore store; + private final ServerPreparer preparer; + private final ClientLauncher launcher; + private final Sharing sharing; + private final IntSupplier preferredPort; + private final BooleanSupplier upnpAllowed; + private final long readinessNanos, stopMillis; + private final ConcurrentMap running = new ConcurrentHashMap<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final Thread shutdownHook; + public VirtualWorldService(WorldStore store, NativeClientService client, Sharing sharing) throws IOException { + this(store, productionPreparer(store, client), client::launch, sharing, client.settings::worldPort, client.settings::upnpAllowed, Duration.ofMinutes(3), Duration.ofSeconds(20)); + } + public VirtualWorldService(WorldStore store, ServerPreparer preparer, ClientLauncher launcher, Sharing sharing, Duration readinessTimeout, Duration stopTimeout) { + this(store, preparer, launcher, sharing, () -> 0, () -> true, readinessTimeout, stopTimeout); + } + public VirtualWorldService(WorldStore store, ServerPreparer preparer, ClientLauncher launcher, Sharing sharing, IntSupplier preferredPort, BooleanSupplier upnpAllowed, Duration readinessTimeout, Duration stopTimeout) { + this.store = Objects.requireNonNull(store); this.preparer = Objects.requireNonNull(preparer); this.launcher = Objects.requireNonNull(launcher); this.sharing = sharing; + this.preferredPort = Objects.requireNonNull(preferredPort); this.upnpAllowed = Objects.requireNonNull(upnpAllowed); + if (readinessTimeout.isZero() || readinessTimeout.isNegative() || readinessTimeout.compareTo(Duration.ofMinutes(10)) > 0 || stopTimeout.isNegative() || stopTimeout.compareTo(Duration.ofSeconds(60)) > 0) throw new IllegalArgumentException("Invalid world lifecycle timeout"); + readinessNanos = readinessTimeout.toNanos(); stopMillis = stopTimeout.toMillis(); + shutdownHook = new Thread(() -> { closed.set(true); stopAll(); }, "AutoPlug-world-shutdown"); Runtime.getRuntime().addShutdownHook(shutdownHook); + } + private static WorldServerPreparer productionPreparer(WorldStore store, NativeClientService client) throws IOException { + ManagedJavaRuntime runtimes = new ManagedJavaRuntime(store.getRoot().resolve("cache/minecraft/runtime")); + return new WorldServerPreparer(store.getRoot().resolve("cache/world-server"), major -> { + Path override = client.settings.javaOverride(major); + if (override != null) { ManagedJavaRuntime.verifyMajor(override, major); return override; } + return runtimes.resolve(major); + }, () -> client.settings.offlineMode); + } + /** Blocking preparation belongs on the existing CLI thread or a UI worker. A failed share still starts the local game. */ + public RunningWorld launch(String id, boolean share) throws Exception { + if (closed.get()) throw new IOException("Virtual world service is closed"); + WorldDefinition world = store.get(id); + if (!world.acceptedEula) throw new IOException("Accept the Minecraft EULA explicitly for world '" + id + "' before launching: https://aka.ms/MinecraftEULA"); + if (share && !upnpAllowed.getAsBoolean()) throw new IOException("UPnP sharing is disabled in Settings; explicitly enable it before playing and sharing a world"); + Profile attached; + try { attached = store.getProfiles().get(world.profileId); } + catch (IOException missing) { throw new IOException("Attached server profile '" + world.profileId + "' is unavailable; restore it before playing this world", missing); } + WorldStore.requireServer(attached); + if (!attached.gameVersion.equals(world.gameVersion) || !attached.loader.equals(world.loader) || attached.type != world.profileType) throw new IOException("The attached profile changed; create a new world from the current profile to preserve this save's version"); + int requestedPort = preferredPort.getAsInt(); + if (requestedPort < 0 || requestedPort > 65535) throw new IOException("Configured world port must be 0 (automatic) or between 1 and 65535"); + int port; + try (ServerSocket available = new ServerSocket()) { + available.bind(new InetSocketAddress(share ? "0.0.0.0" : "127.0.0.1", requestedPort)); port = available.getLocalPort(); + } catch (BindException occupied) { throw new IOException("Configured world port " + requestedPort + " is unavailable. Stop the process using it or change the world port in Settings", occupied); } + String clientProfile = clientProfile(world); + RunningWorld active = new RunningWorld(this, world, port, share); active.launchThread = Thread.currentThread(); + if (running.putIfAbsent(id, active) != null) throw new IOException("World is already starting or running: " + id); + boolean success = false; + try { + if (closed.get()) active.cancelled.set(true); + synchronized (active) { + active.lockChannel = FileChannel.open(store.lockPath(world.id), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + try { active.lock = active.lockChannel.tryLock(); } catch (OverlappingFileLockException alreadyRunning) { throw new IOException("World is running in another dashboard/service", alreadyRunning); } + if (active.lock == null) throw new IOException("World is running in another AutoPlug process"); + WorldDefinition current = store.get(id); + if (!current.acceptedEula || !Files.isDirectory(world.directoryPath, LinkOption.NOFOLLOW_LINKS) + || !current.profileId.equals(world.profileId) || !current.gameVersion.equals(world.gameVersion) || !current.loader.equals(world.loader) + || current.profileType != world.profileType || !Objects.equals(current.clientProfileId, world.clientProfileId) || !Objects.equals(current.serverExecutable, world.serverExecutable)) + throw new IOException("World settings changed while starting; review them and launch again"); + } + checkCancelled(active); + ProcessBuilder command = preparer.prepare(world, port, share); + checkCancelled(active); + synchronized (active) { + checkCancelled(active); command.directory(world.directoryPath.toFile()).redirectErrorStream(true); + active.server = command.start(); drain(active.server.getInputStream(), active, "server"); + } + awaitReady(active); + if (share) tryShare(active); + checkCancelled(active); + Process client = launcher.launch(clientProfile, active.address); + if (client == null) throw new IOException("Client launch was cancelled"); + synchronized (active) { active.client = client; drain(client.getInputStream(), null, "client"); drain(client.getErrorStream(), null, "client-error"); } + checkCancelled(active); + if (!active.server.isAlive()) throw new IOException("World server exited while preparing the client"); + active.launching = false; active.launchThread = null; success = true; + Thread monitor = new Thread(() -> monitor(active), "AutoPlug-world-monitor-" + id); monitor.setDaemon(true); monitor.start(); + return active; + } finally { + try { if (!success) { active.launching = false; active.launchThread = null; active.cancelled.set(true); cleanup(active); } } + finally { active.startupFinished.countDown(); } + } + } + private String clientProfile(WorldDefinition world) throws IOException { + ProfileStore profiles = store.getProfiles(); String id = world.clientProfileId; + if (id == null) { + if (world.profileType == Profile.Type.MODS_SERVER) throw new IOException("Set a matching client MODS profile before launching this server mod world"); + id = automaticClientProfileId(world.id); + Profile existing = null; + for (Profile profile : profiles.list()) if (profile.id.equals(id)) { existing = profile; break; } + if (existing == null) profiles.create(id, Profile.Type.MODS, world.gameVersion, "VANILLA", false); + } + Profile client = profiles.get(id); + if (client.type != Profile.Type.MODS || !client.gameVersion.equals(world.gameVersion) || world.profileType == Profile.Type.MODS_SERVER && !client.loader.equals(world.loader)) throw new IOException("World client profile must match its Minecraft version and mod loader"); + return id; + } + static String automaticClientProfileId(String worldId) { + if (worldId.length() <= 65) return "world-client-" + worldId; + try { + byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(worldId.getBytes(StandardCharsets.UTF_8)); + StringBuilder suffix = new StringBuilder(); for (int i = 0; i < 8; i++) suffix.append(String.format(Locale.ROOT, "%02x", bytes[i] & 255)); + return "world-client-" + worldId.substring(0, 48) + "-" + suffix; + } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + private void awaitReady(RunningWorld active) throws Exception { + long deadline = System.nanoTime() + readinessNanos; + while (System.nanoTime() < deadline) { + checkCancelled(active); + if (!active.server.isAlive()) throw new IOException("World server exited before becoming ready. " + String.join("\n", active.recentOutput())); + MineStat ping = new MineStat("127.0.0.1", active.address.port, 1, MineStat.Request.JSON); + if (ping.isServerUp()) { + ServerStatus status = new ServerStatus(new SavedServer(active.world.name, active.address, "VANILLA"), true, ping.getStrippedMotd(), ping.getVersion(), ping.getProtocol(), ping.getCurrentPlayers(), ping.getMaximumPlayers(), ping.getLatency(), null); + if (!active.world.gameVersion.equals(status.gameVersion())) throw new IOException("Local server advertises a different Minecraft version: " + ping.getVersion()); + if (!active.server.isAlive()) throw new IOException("World server exited during its readiness ping"); return; + } + Thread.sleep(150); + } + throw new IOException("World server did not become ready before timeout. " + String.join("\n", active.recentOutput())); + } + private static void checkCancelled(RunningWorld active) throws InterruptedException { + if (active.cancelled.get() || Thread.currentThread().isInterrupted()) throw new InterruptedException("World startup was cancelled"); + } + public RunningWorld running(String id) { return running.get(id); } + public boolean isRunning(String id) { RunningWorld active = running.get(id); return active != null && !active.closed; } + public String share(String id) throws Exception { + if (!upnpAllowed.getAsBoolean()) throw new IOException("UPnP sharing is disabled in Settings"); + RunningWorld active = running.get(id); + if (active == null || active.server == null || !active.server.isAlive() || active.cancelled.get()) throw new IOException("Launch the world before sharing it"); + if (!active.sharingEnabled) throw new IOException("Restart this world with Share enabled so its server can accept forwarded connections"); + tryShare(active); + if (active.publicAddress() == null) throw new IOException(active.shareFailure); return active.publicAddress(); + } + private void tryShare(RunningWorld active) throws Exception { + checkCancelled(active); + synchronized (active) { if (active.mapping != null) return; } + Mapping mapping = null; + try { + if (sharing == null) throw new IOException("UPnP sharing is unavailable. Forward the server port manually or use a trusted tunnel"); + if (!upnpAllowed.getAsBoolean()) throw new IOException("UPnP sharing was disabled in Settings while the world was starting"); + mapping = sharing.map(active.address.port); + if (mapping == null || mapping.joinAddress() == null) throw new IOException("Router mapping did not return a public address"); + synchronized (active) { + if (active.cancelled.get()) { mapping.close(); mapping = null; throw new InterruptedException("World sharing was cancelled"); } + if (active.mapping != null) { mapping.close(); mapping = null; return; } + active.mapping = mapping; active.shareFailure = null; mapping = null; + } + } catch (InterruptedException cancelled) { throw cancelled; } + catch (Exception unavailable) { active.shareFailure = "Sharing failed: " + unavailable.getMessage() + ". Configure manual forwarding for TCP port " + active.address.port + " or use a trusted tunnel; CGNAT may prevent inbound connections"; } + finally { if (mapping != null) try { mapping.close(); } catch (Exception failed) { active.cleanupFailure = "Port mapping cleanup failed: " + failed.getMessage(); } } + } + private void monitor(RunningWorld active) { + try { while (!active.cancelled.get() && active.server.isAlive() && active.client.isAlive()) Thread.sleep(150); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + finally { active.cancelled.set(true); cleanup(active); } + } + /** Also cancels preparation. No process may be installed into a cancelled lifecycle without immediate cleanup. */ + public void stop(String id) { + RunningWorld active = running.get(id); if (active == null) return; active.cancelled.set(true); + Thread launch = active.launchThread; if (launch != null && launch != Thread.currentThread()) launch.interrupt(); + if (!active.launching) cleanup(active); else synchronized (active) { closeMapping(active); terminate(active.client, false); terminate(active.server, true); } + } + public void stopAll() { + List snapshot = new ArrayList<>(running.values()); + // Signal all preparations first, so another launch cannot advance while an earlier server saves. + for (RunningWorld active : snapshot) { active.cancelled.set(true); Thread launch = active.launchThread; if (launch != null && launch != Thread.currentThread()) launch.interrupt(); } + for (RunningWorld active : snapshot) stop(active.world.id); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + for (RunningWorld active : snapshot) { + Thread launch = active.launchThread; + if (active.startupFinished.getCount() == 0 || launch == Thread.currentThread()) continue; + long remaining = deadline - System.nanoTime(); if (remaining <= 0) break; + try { active.startupFinished.await(Math.max(1, remaining), TimeUnit.NANOSECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; } + } + } + private void cleanup(RunningWorld active) { + boolean interrupted = Thread.interrupted(); + try { + synchronized (active) { + if (active.closed) return; + terminate(active.client, false); terminate(active.server, true); + closeMapping(active); + if (active.lock != null) try { active.lock.release(); } catch (IOException failure) { active.cleanupFailure = failure.getMessage(); } + if (active.lockChannel != null) try { active.lockChannel.close(); } catch (IOException failure) { active.cleanupFailure = failure.getMessage(); } + active.closed = true; running.remove(active.world.id, active); + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + } + /** Called under the lifecycle lock; clearing ownership first makes repeated cancellation/cleanup idempotent. */ + private static void closeMapping(RunningWorld active) { + Mapping mapping = active.mapping; active.mapping = null; + if (mapping != null) try { mapping.close(); } catch (Exception failure) { active.cleanupFailure = "Port mapping cleanup failed: " + failure.getMessage(); } + } + private void terminate(Process process, boolean graceful) { + if (process == null) return; + List descendants = new ArrayList<>(); process.descendants().forEach(descendants::add); + try { + if (process.isAlive() && graceful) { try { process.getOutputStream().write("stop\n".getBytes(StandardCharsets.UTF_8)); process.getOutputStream().flush(); } catch (IOException gone) { } } + if (process.isAlive() && (!graceful || !process.waitFor(stopMillis, TimeUnit.MILLISECONDS))) process.destroy(); + if (process.isAlive() && !process.waitFor(2000, TimeUnit.MILLISECONDS)) process.destroyForcibly(); + if (process.isAlive()) process.waitFor(5000, TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { process.destroyForcibly(); Thread.currentThread().interrupt(); } + finally { + for (ProcessHandle child : descendants) if (child.isAlive()) child.destroyForcibly(); + if (process.isAlive()) process.destroyForcibly(); + try { process.getOutputStream().close(); } catch (IOException ignored) { } + } + } + private static void drain(InputStream input, RunningWorld active, String label) { + Thread drain = new Thread(() -> { + try (InputStream stream = input) { + byte[] bytes = new byte[8192]; ByteArrayOutputStream line = new ByteArrayOutputStream(); int count; + while ((count = stream.read(bytes)) != -1) { + if (active == null) continue; // Client output may contain account details; only drain it. + for (int i = 0; i < count; i++) { + if (bytes[i] == '\n') { active.addOutput(new String(line.toByteArray(), StandardCharsets.UTF_8)); line.reset(); } + else if (line.size() < 2048 && bytes[i] != '\r') line.write(bytes[i]); + } + } + if (active != null && line.size() > 0) active.addOutput(new String(line.toByteArray(), StandardCharsets.UTF_8)); + } catch (IOException closed) { } + }, "AutoPlug-world-output-" + label); drain.setDaemon(true); drain.start(); + } + @Override public void close() { + closed.set(true); stopAll(); + try { Runtime.getRuntime().removeShutdownHook(shutdownHook); } catch (IllegalStateException shuttingDown) { } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldDefinition.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldDefinition.java new file mode 100644 index 00000000..0c5ac4f4 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldDefinition.java @@ -0,0 +1,28 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.Profile; +import java.nio.file.Path; +import java.util.Objects; + +/** Saved world settings; the server installation and save data have their own directory. */ +public final class WorldDefinition { + public final String id, name, profileId, clientProfileId, gameVersion, loader; + public final Profile.Type profileType; + public final Path directoryPath, serverExecutable; + public final boolean acceptedEula; + WorldDefinition(String id, String name, String profileId, String clientProfileId, String gameVersion, + String loader, Profile.Type profileType, Path directoryPath, Path serverExecutable, boolean acceptedEula) { + validateId(id); validateId(profileId); + if (clientProfileId != null) validateId(clientProfileId); + if (name == null || name.trim().isEmpty() || name.length() > 120 || name.chars().anyMatch(Character::isISOControl)) throw new IllegalArgumentException("Invalid world name"); + this.id = id; this.name = name; this.profileId = profileId; this.clientProfileId = clientProfileId; + Profile validated = new Profile(profileId, Objects.requireNonNull(profileType), gameVersion, loader, directoryPath, false); + if (profileType == Profile.Type.MODS) throw new IllegalArgumentException("Worlds require a server profile"); + this.gameVersion = validated.gameVersion; this.loader = validated.loader; + this.profileType = profileType; this.directoryPath = directoryPath.toAbsolutePath().normalize(); + this.serverExecutable = serverExecutable == null ? null : serverExecutable.toAbsolutePath().normalize(); this.acceptedEula = acceptedEula; + } + static void validateId(String id) { if (id == null || !id.matches("[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}")) throw new IllegalArgumentException("Invalid world/profile id"); } + @Override public String toString() { return id + " (" + name + ") [" + gameVersion + ", " + loader + ", profile " + profileId + "]"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldServerPreparer.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldServerPreparer.java new file mode 100644 index 00000000..9b85ebc1 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldServerPreparer.java @@ -0,0 +1,137 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.google.gson.*; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.utils.UtilsMinecraft; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.function.BooleanSupplier; +import java.util.jar.*; + +/** Uses AutoPlug's existing Paper/Purpur/Fabric update protocols without changing global server configuration. */ +public final class WorldServerPreparer implements VirtualWorldService.ServerPreparer { + public static final String PAPER = "https://fill.papermc.io/v3/projects/paper/versions/"; + public static final String PURPUR = "https://api.purpurmc.org/v2/purpur/"; + public static final String FABRIC = "https://meta.fabricmc.net/v2/versions/"; + public static final class Artifact { + public final String url, cacheKey, hash, algorithm; + public final long size; + Artifact(String url, String key, String hash, String algorithm, long size) throws IOException { + URI uri; try { uri = URI.create(url); } catch (RuntimeException invalid) { throw new IOException("Invalid server download URL", invalid); } + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null) throw new IOException("Server downloads require HTTPS"); + this.url = url; this.cacheKey = key; this.hash = hash; this.algorithm = algorithm; this.size = size; + } + } + private final LauncherDownloads downloads; + private final JavaResolver javaResolver; + private final BooleanSupplier offlineMode; + public WorldServerPreparer(Path cacheRoot, JavaResolver javaResolver, BooleanSupplier offlineMode) throws IOException { this(cacheRoot, productionTransport(), javaResolver, offlineMode); } + public WorldServerPreparer(Path cacheRoot, LauncherDownloads.Transport transport, JavaResolver javaResolver, BooleanSupplier offlineMode) throws IOException { + downloads = new LauncherDownloads(cacheRoot, transport); this.javaResolver = Objects.requireNonNull(javaResolver); this.offlineMode = Objects.requireNonNull(offlineMode); + } + @Override public ProcessBuilder prepare(WorldDefinition world, int port, boolean sharing) throws Exception { + if (!world.acceptedEula) throw new IOException("Accept the Minecraft EULA explicitly for this world before launching: https://aka.ms/MinecraftEULA"); + if (sharing && offlineMode.getAsBoolean()) throw new IOException("Sign in with Microsoft before sharing a world; shared servers require online authentication"); + WorldStore.rejectLinks(world.directoryPath); Files.createDirectories(world.directoryPath); + JsonObject game = gameMetadata(world.gameVersion); + int javaMajor = game.has("javaVersion") ? game.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + Path java = javaResolver.resolve(javaMajor); + Path cached; + if (world.serverExecutable != null) { validateSuppliedJar(world.serverExecutable, world.gameVersion); cached = world.serverExecutable; } + else { + Artifact artifact = resolveArtifact(world.loader, world.gameVersion); + cached = downloads.download(artifact.url, artifact.cacheKey, artifact.hash, artifact.algorithm, artifact.size); + validateSuppliedJar(cached, world.gameVersion); + } + Path executable = world.directoryPath.resolve("autoplug-server.jar"); WorldStore.rejectLinks(executable); + Path staging = Files.createTempFile(world.directoryPath, "server-", ".part"); + try { Files.copy(cached, staging, StandardCopyOption.REPLACE_EXISTING); WorldStore.move(staging, executable); } finally { Files.deleteIfExists(staging); } + Properties properties = new Properties(); Path propertyFile = world.directoryPath.resolve("server.properties"); WorldStore.rejectLinks(propertyFile); + if (Files.exists(propertyFile)) { if (Files.size(propertyFile) > 1024 * 1024) throw new IOException("Server properties exceed size limit"); try (InputStream input = Files.newInputStream(propertyFile)) { properties.load(input); } } + properties.setProperty("server-ip", sharing ? "" : "127.0.0.1"); properties.setProperty("server-port", Integer.toString(port)); + properties.setProperty("level-name", "world"); properties.setProperty("enable-status", "true"); properties.setProperty("enable-query", "false"); properties.setProperty("enable-rcon", "false"); + properties.setProperty("online-mode", Boolean.toString(sharing || !offlineMode.getAsBoolean())); + if (!properties.containsKey("motd")) properties.setProperty("motd", world.name); + try (OutputStream output = Files.newOutputStream(propertyFile)) { properties.store(output, "AutoPlug managed virtual world"); } + Path eula = world.directoryPath.resolve("eula.txt"); WorldStore.rejectLinks(eula); + Files.write(eula, Arrays.asList("# Explicit acceptance is recorded in this world's metadata", "eula=true"), StandardCharsets.UTF_8); + return new ProcessBuilder(java.toString(), "-Xms256M", "-Xmx2G", "-jar", executable.toString(), "nogui").directory(world.directoryPath.toFile()).redirectErrorStream(true); + } + public Artifact resolveArtifact(String loader, String version) throws IOException { + if (version == null || !version.matches("[A-Za-z0-9][A-Za-z0-9_.+-]{0,79}")) throw new IOException("Invalid Minecraft version"); + try { + if ("PAPER".equals(loader)) return paper(JsonParser.parseString(downloads.text(PAPER + version + "/builds", 16 * 1024 * 1024)).getAsJsonArray(), version); + if ("PURPUR".equals(loader)) return purpur(downloads.json(PURPUR + version + "/latest"), version); + if ("FABRIC".equals(loader)) { + JsonArray loaders = JsonParser.parseString(downloads.text(FABRIC + "loader/" + version, 8 * 1024 * 1024)).getAsJsonArray(); + JsonArray installers = JsonParser.parseString(downloads.text(FABRIC + "installer", 1024 * 1024)).getAsJsonArray(); + return fabric(loaders, installers, version); + } + throw new IOException("Automatic virtual-world server installation supports PAPER, PURPUR and FABRIC. For " + loader + ", supply a matching runnable server JAR; Spigot BuildTools is an installer, not a server"); + } catch (RuntimeException invalid) { throw new IOException("Invalid official server metadata for " + loader + " " + version, invalid); } + } + private JsonObject gameMetadata(String version) throws IOException { + JsonObject manifest = downloads.json(NativeLauncher.VERSION_MANIFEST); + for (JsonElement entry : manifest.getAsJsonArray("versions")) { + JsonObject item = entry.getAsJsonObject(); if (!version.equals(item.get("id").getAsString())) continue; + Path file = downloads.download(item.get("url").getAsString(), "metadata/" + version + ".json", item.has("sha1") ? item.get("sha1").getAsString() : null, "SHA-1", -1); + try (Reader input = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonObject game = JsonParser.parseReader(input).getAsJsonObject(); if (!version.equals(game.get("id").getAsString())) throw new IOException("Minecraft metadata version does not match the selected world"); return game; + } + } + throw new IOException("Minecraft version is absent from Mojang's official manifest"); + } + static Artifact paper(JsonArray builds, String version) throws IOException { + JsonObject selected = null; + for (JsonElement entry : builds) { JsonObject build = entry.getAsJsonObject(); if (!"STABLE".equals(build.get("channel").getAsString())) continue; if (selected == null || build.get("id").getAsInt() > selected.get("id").getAsInt()) selected = build; } + if (selected == null) throw new IOException("No stable Paper build is available for Minecraft " + version); + JsonObject artifact = selected.getAsJsonObject("downloads").getAsJsonObject("server:default"); + String hash = artifact.getAsJsonObject("checksums").get("sha256").getAsString(); if (!hash.matches("[a-fA-F0-9]{64}")) throw new IOException("Invalid Paper checksum"); + return new Artifact(artifact.get("url").getAsString(), "paper/" + version + "/" + selected.get("id").getAsInt() + ".jar", hash, "SHA-256", artifact.get("size").getAsLong()); + } + static Artifact purpur(JsonObject metadata, String version) throws IOException { + if (!version.equals(metadata.get("version").getAsString()) || !"SUCCESS".equals(metadata.get("result").getAsString())) throw new IOException("Purpur build is unavailable or does not match the world"); + String build = metadata.get("build").getAsString(), hash = metadata.get("md5").getAsString(); + if (!build.matches("[0-9]{1,10}") || !hash.matches("[a-fA-F0-9]{32}")) throw new IOException("Invalid Purpur build/checksum"); + return new Artifact(PURPUR + version + "/" + build + "/download", "purpur/" + version + "/" + build + ".jar", hash, "MD5", -1); + } + static Artifact fabric(JsonArray loaders, JsonArray installers, String version) throws IOException { + String loader = null, installer = null; + for (JsonElement entry : loaders) { JsonObject item = entry.getAsJsonObject(), candidate = item.getAsJsonObject("loader"); if (version.equals(item.getAsJsonObject("intermediary").get("version").getAsString()) && candidate.get("stable").getAsBoolean()) { loader = candidate.get("version").getAsString(); break; } } + for (JsonElement entry : installers) { JsonObject candidate = entry.getAsJsonObject(); if (candidate.get("stable").getAsBoolean()) { installer = candidate.get("version").getAsString(); break; } } + if (loader == null || installer == null) throw new IOException("No stable Fabric server loader/installer supports Minecraft " + version); + if (!loader.matches("[A-Za-z0-9_.+-]{1,80}") || !installer.matches("[A-Za-z0-9_.+-]{1,80}")) throw new IOException("Invalid Fabric version metadata"); + // Fabric's generated server launcher API supplies no checksum; authenticity relies on its official HTTPS endpoint. + return new Artifact(FABRIC + "loader/" + version + "/" + loader + "/" + installer + "/server/jar", "fabric/" + version + "/" + loader + "-" + installer + ".jar", null, "SHA-256", -1); + } + public static void validateSuppliedJar(Path executable, String gameVersion) throws IOException { + WorldStore.rejectLinks(executable); + if (!Files.isRegularFile(executable, LinkOption.NOFOLLOW_LINKS) || !executable.getFileName().toString().endsWith(".jar")) throw new IOException("Supply a runnable server JAR"); + try (JarFile jar = new JarFile(executable.toFile())) { + Manifest manifest = jar.getManifest(); String main = manifest == null ? null : manifest.getMainAttributes().getValue(Attributes.Name.MAIN_CLASS); + if (main == null || main.trim().isEmpty()) throw new IOException("Server JAR has no executable Main-Class"); + if (main.equals("org.spigotmc.builder.Builder") || main.toLowerCase(Locale.ROOT).contains("buildtools")) throw new IOException("Spigot BuildTools is an installer, not a runnable Minecraft server"); + } + String detected = new UtilsMinecraft().getInstalledVersion(executable.toFile()); + if (detected != null && !gameVersion.equals(detected)) throw new IOException("Server JAR targets Minecraft " + detected + "; the world requires " + gameVersion); + } + private static LauncherDownloads.Transport productionTransport() { + return uri -> { + URI current = uri; + for (int redirects = 0; redirects <= 5; redirects++) { + if (!"https".equalsIgnoreCase(current.getScheme()) || current.getUserInfo() != null) throw new IOException("World downloads require HTTPS without credentials"); + HttpURLConnection connection = (HttpURLConnection) current.toURL().openConnection(); connection.setConnectTimeout(15000); connection.setReadTimeout(60000); connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "AutoPlug-NativeWorlds/10.2.0 (https://github.com/Osiris-Team/AutoPlug-Client)"); + int status = connection.getResponseCode(); + if (status >= 300 && status < 400) { String destination = connection.getHeaderField("Location"); connection.disconnect(); if (destination == null) throw new IOException("Missing download redirect"); current = current.resolve(destination); continue; } + if (status != 200) { connection.disconnect(); throw new IOException("World metadata/download returned HTTP " + status); } + return new FilterInputStream(connection.getInputStream()) { @Override public void close() throws IOException { try { super.close(); } finally { connection.disconnect(); } } }; + } + throw new IOException("Too many world download redirects"); + }; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java new file mode 100644 index 00000000..11d1aef4 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java @@ -0,0 +1,140 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; + +/** World metadata lives under worlds/definitions; saves and independent configs under worlds/instances. */ +public final class WorldStore { + private final Path root, definitions, instances, locks; + private final ProfileStore profiles; + public WorldStore(Path root, ProfileStore profiles) throws IOException { + this.root = root.toAbsolutePath().normalize(); this.profiles = Objects.requireNonNull(profiles); + definitions = this.root.resolve("worlds/definitions"); instances = this.root.resolve("worlds/instances"); locks = this.root.resolve("worlds/locks"); + rejectLinks(definitions); rejectLinks(instances); rejectLinks(locks); Files.createDirectories(definitions); Files.createDirectories(instances); Files.createDirectories(locks); + } + public static WorldStore defaultStore() throws IOException { ProfileStore profiles = ProfileStore.defaultStore(); return new WorldStore(profiles.getRoot(), profiles); } + public Path getRoot() { return root; } + public ProfileStore getProfiles() { return profiles; } + Path lockPath(String id) throws IOException { WorldDefinition.validateId(id); return contained(locks, locks.resolve(id + ".lock")); } + public synchronized WorldDefinition create(String name, String profileId) throws IOException { + if (name == null || name.trim().isEmpty()) throw new IOException("Enter a world name"); + String base = name.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "-").replaceAll("^-+|-+$", ""); + if (base.isEmpty()) base = "world"; if (base.length() > 65) base = base.substring(0, 65); + String id = base; int number = 2; + while (Files.exists(definitions.resolve(id + ".properties")) || Files.exists(instances.resolve(id))) id = base + "-" + number++; + return create(id, name, profileId); + } + public synchronized WorldDefinition create(String id, String name, String profileId) throws IOException { + Profile profile = profiles.get(profileId); requireServer(profile); + Path directory = instances.resolve(id); + WorldDefinition world = new WorldDefinition(id, name, profile.id, null, profile.gameVersion, profile.loader, profile.type, directory, null, false); + contained(instances, directory); + if (Files.exists(directory) || Files.exists(definitions.resolve(id + ".properties"))) throw new IOException("World already exists: " + id); + Files.createDirectory(directory); + try { + copyProfile(profile, directory); + save(world); return world; + } catch (IOException | RuntimeException failure) { try { deleteTree(instances, directory); } catch (IOException cleanup) { failure.addSuppressed(cleanup); } throw failure; } + } + public synchronized WorldDefinition get(String id) throws IOException { + WorldDefinition.validateId(id); Path metadata = contained(definitions, definitions.resolve(id + ".properties")); + Properties p = new Properties(); if (Files.size(metadata) > 16384) throw new IOException("World metadata exceeds size limit"); + try (InputStream input = Files.newInputStream(metadata)) { p.load(input); } + catch (IllegalArgumentException malformed) { throw new IOException("Malformed world metadata", malformed); } + try { + if (!id.equals(p.getProperty("id")) || !p.getProperty("acceptedEula", "").matches("true|false")) throw new IllegalArgumentException("Inconsistent world metadata"); + Path directory = contained(instances, instances.resolve(id)); + WorldDefinition world = new WorldDefinition(id, p.getProperty("name"), p.getProperty("profileId"), p.getProperty("clientProfileId"), + p.getProperty("gameVersion"), p.getProperty("loader"), Profile.Type.valueOf(p.getProperty("profileType")), directory, + p.containsKey("serverExecutable") ? Paths.get(p.getProperty("serverExecutable")) : null, Boolean.parseBoolean(p.getProperty("acceptedEula"))); + return world; + } catch (RuntimeException malformed) { throw new IOException("Invalid world metadata: " + id, malformed); } + } + public synchronized List list() throws IOException { + List result = new ArrayList<>(); + try (DirectoryStream files = Files.newDirectoryStream(definitions, "*.properties")) { + for (Path file : files) { String name = file.getFileName().toString(); result.add(get(name.substring(0, name.length() - 11))); } + } + result.sort(Comparator.comparing(world -> world.id)); return Collections.unmodifiableList(result); + } + public synchronized WorldDefinition setClientProfile(String id, String clientId) throws IOException { + WorldDefinition old = get(id); + if (clientId != null) { + Profile client = profiles.get(clientId); + if (client.type != Profile.Type.MODS || !old.gameVersion.equals(client.gameVersion)) throw new IOException("Choose a client MODS profile for Minecraft " + old.gameVersion); + if (old.profileType == Profile.Type.MODS_SERVER && !old.loader.equals(client.loader)) throw new IOException("The client and server mod loaders must match"); + } + return save(new WorldDefinition(old.id, old.name, old.profileId, clientId, old.gameVersion, old.loader, old.profileType, old.directoryPath, old.serverExecutable, old.acceptedEula)); + } + public synchronized WorldDefinition setServerExecutable(String id, Path executable) throws IOException { + WorldDefinition old = get(id); if (executable != null) WorldServerPreparer.validateSuppliedJar(executable, old.gameVersion); + return save(new WorldDefinition(old.id, old.name, old.profileId, old.clientProfileId, old.gameVersion, old.loader, old.profileType, old.directoryPath, executable, old.acceptedEula)); + } + /** Only call after an explicit user action accepting https://aka.ms/MinecraftEULA for this world. */ + public synchronized WorldDefinition acceptEula(String id, boolean accepted) throws IOException { + WorldDefinition old = get(id); + return save(new WorldDefinition(old.id, old.name, old.profileId, old.clientProfileId, old.gameVersion, old.loader, old.profileType, old.directoryPath, old.serverExecutable, accepted)); + } + /** Service consumers must stop the world before deleting it; an OS-held process lock also prevents deletion. */ + public synchronized void delete(String id) throws IOException { + WorldDefinition world = get(id); + // This stable lock is outside save data so Windows deletion can retain ownership until it finishes. + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(lockPath(id), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + java.nio.channels.FileLock lock; + try { lock = channel.tryLock(); } catch (java.nio.channels.OverlappingFileLockException running) { throw new IOException("Stop the world before deleting it", running); } + if (lock == null) throw new IOException("Stop the world before deleting it"); + try { deleteTree(instances, world.directoryPath); Files.delete(contained(definitions, definitions.resolve(id + ".properties"))); } + finally { lock.release(); } + } + } + private WorldDefinition save(WorldDefinition world) throws IOException { + Properties p = new Properties(); p.setProperty("id", world.id); p.setProperty("name", world.name); p.setProperty("profileId", world.profileId); + p.setProperty("gameVersion", world.gameVersion); p.setProperty("loader", world.loader); p.setProperty("profileType", world.profileType.name()); + p.setProperty("acceptedEula", Boolean.toString(world.acceptedEula)); + if (world.clientProfileId != null) p.setProperty("clientProfileId", world.clientProfileId); + if (world.serverExecutable != null) p.setProperty("serverExecutable", world.serverExecutable.toString()); + Path temporary = Files.createTempFile(definitions, world.id + "-", ".part"); + try { try (OutputStream output = Files.newOutputStream(temporary)) { p.store(output, "AutoPlug virtual world"); } move(temporary, contained(definitions, definitions.resolve(world.id + ".properties"))); } + finally { Files.deleteIfExists(temporary); } + return world; + } + private void copyProfile(Profile profile, Path directory) throws IOException { + rejectLinks(profile.directoryPath); + for (String name : Arrays.asList(profile.type == Profile.Type.PLUGINS ? "plugins" : "mods", "config", "defaultconfigs")) { + Path source = profile.directoryPath.resolve(name); if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS)) continue; + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path from, BasicFileAttributes attributes) throws IOException { rejectLinks(from); Files.createDirectories(directory.resolve(profile.directoryPath.relativize(from))); return FileVisitResult.CONTINUE; } + @Override public FileVisitResult visitFile(Path from, BasicFileAttributes attributes) throws IOException { + if (!attributes.isRegularFile() || Files.isSymbolicLink(from)) throw new IOException("Profile contains linked/special files"); + Path to = directory.resolve(profile.directoryPath.relativize(from)); + if (from.getFileName().toString().endsWith(".jar") || from.getFileName().toString().endsWith(".jar.disabled")) profiles.getCache().link(profiles.getCache().store(from), to); + else Files.copy(from, to, StandardCopyOption.COPY_ATTRIBUTES); return FileVisitResult.CONTINUE; + } + }); + } + } + static void requireServer(Profile profile) throws IOException { + if (profile.type == Profile.Type.MODS) throw new IOException("Virtual worlds require a PLUGINS or MODS_SERVER profile"); + if (profile.type == Profile.Type.PLUGINS && profile.loader.matches("FABRIC|QUILT|FORGE|NEOFORGE")) + throw new IOException(profile.loader + " loads server mods, not plugin profiles; choose MODS_SERVER or explicitly name your custom hybrid server loader"); + if (profile.type == Profile.Type.MODS_SERVER && profile.loader.matches("PAPER|PURPUR|SPIGOT|BUKKIT")) + throw new IOException(profile.loader + " loads plugins, not server mod profiles; choose PLUGINS or explicitly name your custom hybrid server loader"); + } + static Path contained(Path root, Path candidate) throws IOException { + Path base = root.toAbsolutePath().normalize(), target = candidate.toAbsolutePath().normalize(); + if (!target.startsWith(base) || target.equals(base)) throw new IOException("World path escapes its managed directory"); rejectLinks(target); return target; + } + static void rejectLinks(Path path) throws IOException { for (Path cursor = path.toAbsolutePath().normalize(); cursor != null; cursor = cursor.getParent()) if (Files.isSymbolicLink(cursor)) throw new IOException("World paths cannot contain symbolic links"); } + static void move(Path from, Path to) throws IOException { try { Files.move(from, to, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException unavailable) { Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); } } + static void deleteTree(Path root, Path path) throws IOException { + contained(root, path); + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } + @Override public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { if (failure != null) throw failure; Files.delete(directory); return FileVisitResult.CONTINUE; } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapper.java b/src/main/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapper.java new file mode 100644 index 00000000..d16f7648 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapper.java @@ -0,0 +1,193 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds.network; + +import com.osiris.autoplug.client.worlds.VirtualWorldService; +import org.w3c.dom.*; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Opt-in IPv4 IGD control. No discovery or router request occurs until map is called. */ +public final class UpnpPortMapper implements VirtualWorldService.Sharing { + public interface Discovery { List discover() throws IOException; } + public static final class Gateway { + public final URI description; public final InetAddress address, localAddress; + public Gateway(URI description, InetAddress address, InetAddress localAddress) { + this.description = Objects.requireNonNull(description); this.address = Objects.requireNonNull(address); this.localAddress = Objects.requireNonNull(localAddress); + } + } + private final Discovery discovery; + private final int leaseSeconds, renewSeconds; + private static final int MAX_XML = 512 * 1024; + public UpnpPortMapper() { this(UpnpPortMapper::discoverLocalGateways, 1800, 600); } + public UpnpPortMapper(Discovery discovery, int leaseSeconds, int renewSeconds) { + if (leaseSeconds < 3 || renewSeconds < 1 || renewSeconds >= leaseSeconds) throw new IllegalArgumentException("Invalid finite mapping lease"); + this.discovery = Objects.requireNonNull(discovery); this.leaseSeconds = leaseSeconds; this.renewSeconds = renewSeconds; + } + @Override public VirtualWorldService.Mapping map(int localPort) throws Exception { + if (localPort < 1 || localPort > 65535) throw new IllegalArgumentException("Invalid server port"); + Exception last = null; + for (Gateway gateway : discovery.discover()) { + try { + Service service = service(gateway); + String external = text(call(service, "GetExternalIPAddress", Collections.emptyMap()), "NewExternalIPAddress"); + if (!publicIpv4(external)) throw new IOException("The router has no public IPv4 address (possibly CGNAT/double NAT). Use manual forwarding or an explicitly configured tunnel."); + Map key = key(localPort); + try { call(service, "GetSpecificPortMappingEntry", key); throw new IOException("The requested router port already has a mapping; it will not be overwritten."); } + catch (SoapFailure missing) { if (missing.code != 714 && missing.code != 713) throw missing; } + String description = "AutoPlug-" + UUID.randomUUID(); + Map args = new LinkedHashMap<>(key); + args.put("NewInternalPort", String.valueOf(localPort)); args.put("NewInternalClient", gateway.localAddress.getHostAddress()); + args.put("NewEnabled", "1"); args.put("NewPortMappingDescription", description); args.put("NewLeaseDuration", String.valueOf(leaseSeconds)); + boolean attempted = false; + try { + attempted = true; call(service, "AddPortMapping", args); + OwnedMapping mapping = new OwnedMapping(service, key, args, external + ":" + localPort); + mapping.requireOwnership(); mapping.startRenewal(); return mapping; + } catch (Exception failure) { + if (attempted) try { removeOwned(service, key, args); } catch (Exception cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } + } catch (Exception failure) { last = failure; } + } + throw new IOException("Sharing failed: " + (last == null ? "no compatible local UPnP gateway was found" : last.getMessage()) + ". The world can still be played locally; check router settings or configure forwarding/tunneling yourself.", last); + } + private final class OwnedMapping implements VirtualWorldService.Mapping { + final Service service; final Map key, args; final String address; + final AtomicBoolean closed = new AtomicBoolean(); final ScheduledExecutorService renewal; + volatile Exception renewalFailure; + OwnedMapping(Service service, Map key, Map args, String address) { + this.service = service; this.key = key; this.args = args; this.address = address; + renewal = Executors.newSingleThreadScheduledExecutor(task -> { Thread thread = new Thread(task, "AutoPlug-UPnP-lease"); thread.setDaemon(true); return thread; }); + } + synchronized void requireOwnership() throws Exception { verifyOwned(call(service, "GetSpecificPortMappingEntry", key), args); } + void startRenewal() { renewal.scheduleWithFixedDelay(() -> { + synchronized (this) { + if (closed.get()) return; + try { requireOwnership(); call(service, "AddPortMapping", args); requireOwnership(); } + catch (Exception failure) { renewalFailure = failure; renewal.shutdown(); } + } + }, renewSeconds, renewSeconds, TimeUnit.SECONDS); } + @Override public String joinAddress() { + if (closed.get()) throw new IllegalStateException("World sharing has stopped"); + if (renewalFailure != null) throw new IllegalStateException("UPnP lease renewal failed; sharing may have expired: " + renewalFailure.getMessage(), renewalFailure); + return address; + } + @Override public synchronized void close() throws Exception { + if (!closed.compareAndSet(false, true)) return; + renewal.shutdownNow(); removeOwned(service, key, args); + } + } + private static void removeOwned(Service service, Map key, Map args) throws Exception { + Document entry; + try { entry = call(service, "GetSpecificPortMappingEntry", key); } + catch (SoapFailure missing) { if (missing.code == 714 || missing.code == 713) return; throw missing; } + verifyIdentity(entry, args); call(service, "DeletePortMapping", key); + } + private static void verifyOwned(Document entry, Map args) throws IOException { + verifyIdentity(entry, args); + String lease = text(entry, "NewLeaseDuration"); + try { if (Integer.parseInt(lease) <= 0) throw new IOException("Router did not honor the finite lease; permanent mappings are not accepted"); } + catch (NumberFormatException bad) { throw new IOException("Router returned an invalid lease", bad); } + } + private static void verifyIdentity(Document entry, Map args) throws IOException { + for (String name : new String[]{"NewInternalPort", "NewInternalClient", "NewPortMappingDescription"}) if (!args.get(name).equals(text(entry, name))) throw new IOException("Router mapping ownership changed; refusing to renew or delete another mapping"); + if (!Arrays.asList("1", "true").contains(text(entry, "NewEnabled"))) throw new IOException("Router mapping was not enabled"); + } + private static Map key(int port) { Map values = new LinkedHashMap<>(); values.put("NewRemoteHost", ""); values.put("NewExternalPort", String.valueOf(port)); values.put("NewProtocol", "TCP"); return values; } + private static final class Service { final Gateway gateway; final URI control; final String type; Service(Gateway gateway, URI control, String type) { this.gateway = gateway; this.control = control; this.type = type; } } + private static Service service(Gateway gateway) throws Exception { + validateEndpoint(gateway.description, gateway.address); + Document description = xml(request(gateway.description, null, null)); + URI base = gateway.description; String advertisedBase = text(description, "URLBase"); + if (!advertisedBase.isEmpty()) { base = gateway.description.resolve(advertisedBase); validateEndpoint(base, gateway.address); } + NodeList services = description.getElementsByTagNameNS("*", "service"); + for (int i = 0; i < services.getLength(); i++) { + Element service = (Element)services.item(i); String type = text(service, "serviceType"); + if (type.matches("urn:schemas-upnp-org:service:WAN(IP|PPP)Connection:[12]")) { + String controlPath = text(service, "controlURL"); if (controlPath.isEmpty()) continue; + URI control = base.resolve(controlPath); validateEndpoint(control, gateway.address); return new Service(gateway, control, type); + } + } + throw new IOException("Gateway description has no WANIP/WANPPP connection service"); + } + static void validateEndpoint(URI endpoint, InetAddress peer) throws IOException { + if (!("http".equalsIgnoreCase(endpoint.getScheme()) || "https".equalsIgnoreCase(endpoint.getScheme())) || endpoint.getHost() == null || endpoint.getUserInfo() != null || endpoint.getFragment() != null || endpoint.toString().length() > 4096) throw new IOException("Invalid UPnP control endpoint"); + // Literal addresses prevent DNS rebinding; every description/control request stays with its SSDP peer. + String host = endpoint.getHost(); if (!host.matches("[0-9.]+") || !InetAddress.getByName(host).equals(peer)) throw new IOException("UPnP control endpoint must remain on the responding gateway"); + if (!(peer instanceof Inet4Address)) throw new IOException("IPv6-only UPnP needs manual sharing configuration"); + } + private static Document call(Service service, String action, Map arguments) throws Exception { + StringBuilder body = new StringBuilder(""); + for (Map.Entry argument : arguments.entrySet()) body.append('<').append(argument.getKey()).append('>').append(escape(argument.getValue())).append("'); + body.append(""); + Document result = xml(request(service.control, service.type + "#" + action, body.toString().getBytes(StandardCharsets.UTF_8))); + String fault = text(result, "errorCode"); + if (!fault.isEmpty()) { int code; try { code = Integer.parseInt(fault); } catch (NumberFormatException bad) { code = -1; } throw new SoapFailure(code, "UPnP " + action + " error " + code + ": " + text(result, "errorDescription")); } + if (result.getElementsByTagNameNS("*", action + "Response").getLength() != 1) throw new IOException("Malformed UPnP " + action + " response"); + return result; + } + private static final class SoapFailure extends IOException { final int code; SoapFailure(int code, String message) { super(message); this.code = code; } } + private static byte[] request(URI endpoint, String action, byte[] body) throws IOException { + HttpURLConnection connection = (HttpURLConnection)endpoint.toURL().openConnection(Proxy.NO_PROXY); + connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(3000); connection.setReadTimeout(3000); + try { + if (body != null) { connection.setRequestMethod("POST"); connection.setDoOutput(true); connection.setFixedLengthStreamingMode(body.length); connection.setRequestProperty("Content-Type", "text/xml; charset=\"utf-8\""); connection.setRequestProperty("SOAPAction", '"' + action + '"'); try (OutputStream output = connection.getOutputStream()) { output.write(body); } } + int status = connection.getResponseCode(); if (status >= 300 && status < 400) throw new IOException("UPnP redirects are not followed"); + if (connection.getContentLengthLong() > MAX_XML) throw new IOException("UPnP XML response is too large"); + InputStream stream = status >= 400 ? connection.getErrorStream() : connection.getInputStream(); if (stream == null) throw new IOException("Empty UPnP HTTP response (" + status + ")"); + try (InputStream input = stream; ByteArrayOutputStream bytes = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; int count; while ((count = input.read(buffer)) != -1) { if (bytes.size() + count > MAX_XML) throw new IOException("UPnP XML response is too large"); bytes.write(buffer, 0, count); } return bytes.toByteArray(); } + } finally { connection.disconnect(); } + } + static Document xml(byte[] bytes) throws Exception { + if (bytes.length > MAX_XML) throw new IOException("UPnP XML response is too large"); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); + } + private static String text(Node node, String localName) { + NodeList items = node instanceof Document ? ((Document)node).getElementsByTagNameNS("*", localName) : ((Element)node).getElementsByTagNameNS("*", localName); + return items.getLength() == 0 ? "" : items.item(0).getTextContent().trim(); + } + private static String escape(String value) { return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); } + static boolean publicIpv4(String value) { + if (value == null || !value.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}")) return false; + String[] parts = value.split("\\."); int[] octets = new int[4]; for (int i = 0; i < 4; i++) { octets[i] = Integer.parseInt(parts[i]); if (octets[i] > 255) return false; } + int a = octets[0], b = octets[1]; + return !(a == 0 || a == 10 || a == 127 || a >= 224 || a == 169 && b == 254 || a == 172 && b >= 16 && b <= 31 || a == 192 && b == 168 || a == 100 && b >= 64 && b <= 127 || a == 192 && b == 0 || a == 198 && (b == 18 || b == 19 || b == 51) || a == 203 && b == 0); + } + static List discoverLocalGateways() throws IOException { + List result = new ArrayList<>(); Set seen = new HashSet<>(); + byte[] search = ("M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n").getBytes(StandardCharsets.US_ASCII); + try (MulticastSocket socket = new MulticastSocket(0)) { + socket.setTimeToLive(1); socket.setSoTimeout(300); + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); int sent = 0; + while (interfaces.hasMoreElements() && sent < 8) { + NetworkInterface network = interfaces.nextElement(); if (!network.isUp() || network.isLoopback() || !network.supportsMulticast()) continue; + boolean ipv4 = Collections.list(network.getInetAddresses()).stream().anyMatch(address -> address instanceof Inet4Address && address.isSiteLocalAddress()); if (!ipv4) continue; + try { socket.setNetworkInterface(network); socket.send(new DatagramPacket(search, search.length, InetAddress.getByName("239.255.255.250"), 1900)); sent++; } catch (IOException unavailable) { } + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (System.nanoTime() < deadline && result.size() < 16) { + if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("UPnP discovery cancelled"); + DatagramPacket packet = new DatagramPacket(new byte[8192], 8192); + try { socket.receive(packet); } catch (SocketTimeoutException again) { continue; } + InetAddress peer = packet.getAddress(); if (!(peer instanceof Inet4Address) || !(peer.isSiteLocalAddress() || peer.isLinkLocalAddress())) continue; + String response = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.US_ASCII); if (!response.startsWith("HTTP/1.1 200 ")) continue; + for (String line : response.split("\r?\n")) if (line.toLowerCase(Locale.ROOT).startsWith("location:")) { + try { + URI location = URI.create(line.substring(9).trim()); validateEndpoint(location, peer); + if (seen.add(location.toString())) try (DatagramSocket route = new DatagramSocket()) { route.connect(peer, 1900); InetAddress local = route.getLocalAddress(); if (local instanceof Inet4Address && !local.isAnyLocalAddress() && !local.isLoopbackAddress()) result.add(new Gateway(location, peer, local)); } + } catch (IOException | IllegalArgumentException invalid) { } + } + } + } + return result; + } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java b/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java new file mode 100644 index 00000000..1392d177 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java @@ -0,0 +1,106 @@ +package com.osiris.autoplug.client.browser; + +import com.osiris.autoplug.client.utils.MineStat; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class ServerBrowserTest { + @TempDir Path temp; + @Test void addressesValidatePortsAndBracketedIpv6WithoutAcceptingUrlsOrCommandFlags() { + assertEquals("example.org", ServerAddress.parse(" EXAMPLE.ORG:25565 ").toString()); + assertEquals("[::1]:25566", ServerAddress.parse("[::1]:25566").toString()); + for (String invalid : new String[]{"", "https://server", "-x", "host:0", "host:65536", "host:wat", "::1", "host\n--flag", "host/path"}) assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse(invalid)); + } + @Test void rawAndGzipNbtRoundTripAllTagTypesAndPreserveUnknownServerFields() throws Exception { + Map root = new LinkedHashMap<>(); + root.put("byte", new MinecraftServersDat.Tag(1, (byte)1)); root.put("short", new MinecraftServersDat.Tag(2, (short)2)); root.put("int", new MinecraftServersDat.Tag(3, 3)); root.put("long", new MinecraftServersDat.Tag(4, 4L)); + root.put("float", new MinecraftServersDat.Tag(5, 1.25f)); root.put("double", new MinecraftServersDat.Tag(6, 2.5)); root.put("bytes", new MinecraftServersDat.Tag(7, new byte[]{1,2})); + root.put("string", new MinecraftServersDat.Tag(8, "Bos 🌲\0 café")); root.put("list", new MinecraftServersDat.Tag(9, new MinecraftServersDat.ListValue(8, Collections.singletonList(new MinecraftServersDat.Tag(8, "item"))))); + root.put("compound", new MinecraftServersDat.Tag(10, new LinkedHashMap())); root.put("ints", new MinecraftServersDat.Tag(11, new int[]{-1,2})); root.put("longs", new MinecraftServersDat.Tag(12, new long[]{-3,4})); + Map server = new LinkedHashMap<>(); server.put("name", new MinecraftServersDat.Tag(8, "🌲 World")); server.put("ip", new MinecraftServersDat.Tag(8, "localhost:25566")); server.put("icon", new MinecraftServersDat.Tag(8, "data:image/png;base64,fixture")); server.put("acceptTextures", new MinecraftServersDat.Tag(1, (byte)1)); + root.put("servers", new MinecraftServersDat.Tag(9, new MinecraftServersDat.ListValue(10, Collections.singletonList(new MinecraftServersDat.Tag(10, server))))); + for (boolean compressed : new boolean[]{false, true}) { + MinecraftServersDat.Document original = new MinecraftServersDat.Document("Root", new MinecraftServersDat.Tag(10, root), compressed); + byte[] before = bytes(original); MinecraftServersDat.Document loaded = MinecraftServersDat.read(new ByteArrayInputStream(before)); + assertArrayEquals(before, bytes(loaded)); assertEquals("🌲 World", loaded.servers().get(0).name); + MinecraftServersDat.Document changed = loaded.withServers(Collections.singletonList(new SavedServer("Renamed", ServerAddress.parse("localhost:25566"), "FABRIC"))); + assertEquals("Renamed", MinecraftServersDat.read(new ByteArrayInputStream(bytes(changed))).servers().get(0).name); + assertTrue(new String(bytes(new MinecraftServersDat.Document(changed.name, changed.root, false)), StandardCharsets.ISO_8859_1).contains("data:image/png;base64,fixture")); + } + } + @Test void nbtRejectsTruncationNegativeArrayLengthsDuplicatesAndTrailingData() throws Exception { + byte[] valid = bytes(MinecraftServersDat.empty()); + assertThrows(IOException.class, () -> MinecraftServersDat.read(new ByteArrayInputStream(Arrays.copyOf(valid, valid.length - 1)))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputStream data = new DataOutputStream(out); data.writeByte(10); data.writeUTF(""); data.writeByte(7); data.writeUTF("bad"); data.writeInt(-1); + assertThrows(IOException.class, () -> MinecraftServersDat.read(new ByteArrayInputStream(out.toByteArray()))); + byte[] trailing = Arrays.copyOf(valid, valid.length + 1); assertThrows(IOException.class, () -> MinecraftServersDat.read(new ByteArrayInputStream(trailing))); + out.reset(); data.writeByte(10); data.writeUTF(""); for (int i = 0; i < 2; i++) { data.writeByte(1); data.writeUTF("same"); data.writeByte(i); } data.writeByte(0); + assertThrows(IOException.class, () -> MinecraftServersDat.read(new ByteArrayInputStream(out.toByteArray()))); + } + @Test void catalogImportDeduplicatesAddressesAndExportPreservesIconsWithBackup() throws Exception { + SavedServer original = new SavedServer("Local", ServerAddress.parse("localhost"), "FABRIC"); + ServerCatalog catalog = new ServerCatalog(temp.resolve("autoplug")); catalog.save(Collections.singletonList(original)); + Path vanilla = temp.resolve("servers.dat"); + MinecraftServersDat.Document document = MinecraftServersDat.empty().withServers(Arrays.asList(new SavedServer("Duplicate", ServerAddress.parse("localhost:25565"), "VANILLA"), new SavedServer("Other", ServerAddress.parse("example.org"), "VANILLA"))); + Files.write(vanilla, bytes(document)); assertEquals(1, catalog.importVanilla(vanilla)); assertEquals(2, catalog.list().size()); assertEquals("FABRIC", catalog.list().get(0).loaderHint); + byte[] previous = Files.readAllBytes(vanilla); Path backup = catalog.exportVanilla(vanilla); assertArrayEquals(previous, Files.readAllBytes(backup)); assertEquals("Local", MinecraftServersDat.read(vanilla).servers().get(0).name); + assertThrows(IOException.class, () -> catalog.save(Arrays.asList(original, original))); + } + @Test void actualExistingMineStatParsesProtocolAndStringAndStructuredUnicodeMotds() throws Exception { + for (String description : new String[]{"\"§aCafé 🌲\"", "{\"text\":\"§aCafé\",\"extra\":[\" 🌲\",{\"text\":\"!\"}]}"}) { + String json = "{\"version\":{\"name\":\"Paper 1.20.4\",\"protocol\":765},\"players\":{\"online\":3,\"max\":20},\"description\":" + description + "}"; + MineStat status = fixture(json.getBytes(StandardCharsets.UTF_8), 0, false); + assertTrue(status.isServerUp()); assertEquals(765, status.getProtocol()); assertEquals(3, status.getCurrentPlayers()); assertEquals(20, status.getMaximumPlayers()); assertTrue(status.getStrippedMotd().startsWith("Café 🌲")); assertTrue(status.getLatency() >= 0); + } + } + @Test void staleUiEditsRetainNewlyImportedServersAndRejectConflictingEdits() throws Exception { + ServerCatalog catalog = new ServerCatalog(temp.resolve("autoplug")); SavedServer selected = new SavedServer("Local", ServerAddress.parse("localhost"), "VANILLA"); catalog.save(Collections.singletonList(selected)); + Path vanilla = temp.resolve("servers.dat"); Files.write(vanilla, bytes(MinecraftServersDat.empty().withServers(Collections.singletonList(new SavedServer("Imported", ServerAddress.parse("example.org"), "VANILLA"))))); + catalog.importVanilla(vanilla); SavedServer renamed = new SavedServer("Renamed", selected.address, "FABRIC"); catalog.edit(selected, renamed); + assertEquals(2, catalog.list().size()); assertEquals("Imported", catalog.list().get(1).name); + assertThrows(IOException.class, () -> catalog.edit(selected, new SavedServer("Stale", selected.address, "FORGE"))); assertThrows(IOException.class, () -> catalog.remove(selected)); + catalog.remove(renamed); assertEquals(1, catalog.list().size()); assertEquals("Imported", catalog.list().get(0).name); + } + @Test void mineStatRejectsOversizedTruncatedOrInvalidStatusAndBoundsReadTimeout() throws Exception { + assertFalse(fixture(new byte[]{'{','}'}, 0, false).isServerUp()); + assertFalse(fixture(new byte[0], 1024 * 1024 + 1, false).isServerUp()); + assertFalse(fixture(new byte[0], 100, false).isServerUp()); + long start = System.nanoTime(); MineStat timeout = fixture(new byte[0], 0, true); + assertEquals(MineStat.Retval.TIMEOUT, timeout.pingResult); assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 3500); + } + @Test void autoResolutionRejectsProxyVersionRangesRatherThanGuessing() { + SavedServer server = new SavedServer("Test", ServerAddress.parse("localhost"), "FORGE"); + assertEquals("1.20.4", new ServerStatus(server, true, "", "Paper 1.20.4", 765, 0, 20, 1, "").gameVersion()); + assertEquals("26.3", new ServerStatus(server, true, "", "26.3", 0, 0, 20, 1, "").gameVersion()); + assertEquals("26.4-snapshot-2", new ServerStatus(server, true, "", "26.4-snapshot-2", 0, 0, 20, 1, "").gameVersion()); + for (String version : new String[]{"1.20.2-pre1", "1.20.2-rc1", "26.1-pre-1", "26.1-rc-1"}) assertEquals(version, new ServerStatus(server, true, "", version, 0, 0, 20, 1, "").gameVersion()); + assertThrows(IllegalStateException.class, () -> new ServerStatus(server, true, "", "1.8 - 1.21.1", 0, 0, 20, 1, "").gameVersion()); + assertThrows(IllegalStateException.class, () -> new ServerStatus(server, false, "", "1.20.4", 0, 0, 20, 1, "").gameVersion()); + } + private static byte[] bytes(MinecraftServersDat.Document document) throws Exception { ByteArrayOutputStream output = new ByteArrayOutputStream(); MinecraftServersDat.write(document, output); return output.toByteArray(); } + private static MineStat fixture(byte[] json, int declaredSize, boolean delay) throws Exception { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future responder = executor.submit(() -> { + try (Socket socket = server.accept()) { + socket.setSoTimeout(2000); DataInputStream input = new DataInputStream(socket.getInputStream()); int length = varInt(input); byte[] handshake = new byte[length]; input.readFully(handshake); assertEquals(0, handshake[0]); assertEquals(1, varInt(input)); assertEquals(0, input.readUnsignedByte()); + if (delay) { Thread.sleep(1400); return; } + DataOutputStream output = new DataOutputStream(socket.getOutputStream()); + if (declaredSize > 0) { varInt(output, declaredSize); output.flush(); return; } + ByteArrayOutputStream packet = new ByteArrayOutputStream(); DataOutputStream payload = new DataOutputStream(packet); varInt(payload, 0); varInt(payload, json.length); payload.write(json); varInt(output, packet.size()); output.write(packet.toByteArray()); output.flush(); + } catch (Exception exception) { throw new RuntimeException(exception); } + }); + try { MineStat result = new MineStat(InetAddress.getLoopbackAddress().getHostAddress(), server.getLocalPort(), 1, MineStat.Request.JSON); responder.get(4, TimeUnit.SECONDS); return result; } + finally { executor.shutdownNow(); } + } + } + private static int varInt(DataInputStream input) throws Exception { int value = 0; for (int i = 0; i < 5; i++) { int next = input.readUnsignedByte(); value |= (next & 127) << (7 * i); if ((next & 128) == 0) return value; } throw new IOException("Invalid varint"); } + private static void varInt(DataOutputStream output, int value) throws Exception { do { int next = value & 127; value >>>= 7; output.writeByte(next | (value == 0 ? 0 : 128)); } while (value != 0); } +} diff --git a/src/test/java/com/osiris/autoplug/client/clientapp/DashboardLayoutTest.java b/src/test/java/com/osiris/autoplug/client/clientapp/DashboardLayoutTest.java new file mode 100644 index 00000000..6f1dd267 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/clientapp/DashboardLayoutTest.java @@ -0,0 +1,43 @@ +package com.osiris.autoplug.client.clientapp; + +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.ui.client.ClientDashboard; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Offscreen rendering of production Swing panels with local fixture data; no desktop automation. */ +class DashboardLayoutTest { + @TempDir Path temp; + @Test void realClientPanelsRenderAtMinimumAndLargeWindowSizesWithoutNetworkOrAccountActivity() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("autoplug")); store.create("survival-1-20-4", Profile.Type.MODS, "1.20.4", "fabric", false); store.create("client-base", Profile.Type.MODS, "1.21.1", "fabric", true); store.create("hub-plugins", Profile.Type.PLUGINS, "1.20.4", "paper", true); + ServerCatalog catalog = new ServerCatalog(store.getRoot()); catalog.save(Arrays.asList(new SavedServer("Survival world", ServerAddress.parse("play.example.org"), "FABRIC"), new SavedServer("Local testing", ServerAddress.parse("localhost:25566"), "VANILLA"))); + NativeClientService service = new NativeClientService(store, catalog, new ClientSettings(store.getRoot()), (profile, account, address) -> { throw new AssertionError("Rendering must not launch a process"); }, new NativeClientService.Migration() { + public ProfileUpdater.Plan check(Profile profile) { throw new AssertionError("Rendering must not access an updater"); } + public void apply(ProfileUpdater.Plan plan, boolean confirm) { throw new AssertionError("Rendering must not apply updates"); } + }); + Path destination = Paths.get("target", "dashboard-fixture-render"); Files.createDirectories(destination); + WorldClientContext.of(service).worlds.create("Workshop world", "hub-plugins"); + SwingUtilities.invokeAndWait(() -> { + assertTrue(FlatLightLaf.setup()); ClientDashboard dashboard = new ClientDashboard(service, null, false); assertEquals(4, dashboard.getTabCount()); + for (int width : new int[]{800, 1180}) for (int tab = 0; tab < dashboard.getTabCount(); tab++) { + dashboard.setSelectedIndex(tab); dashboard.setSize(width, 650); layout(dashboard); + assertTrue(dashboard.getSelectedComponent().getWidth() > 500); assertTrue(dashboard.getSelectedComponent().getHeight() > 450); + BufferedImage image = new BufferedImage(width, 650, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = image.createGraphics(); graphics.setColor(UIManager.getColor("Panel.background")); graphics.fillRect(0, 0, width, 650); dashboard.printAll(graphics); graphics.dispose(); + try { ImageIO.write(image, "png", destination.resolve("client-tab-" + tab + "-" + width + ".png").toFile()); } catch (Exception failure) { throw new RuntimeException(failure); } + } + dashboard.release(); + }); + assertEquals(3, store.list().size()); assertEquals(2, catalog.list().size()); + WorldClientContext.of(service).lifecycle.close(); + } + private static void layout(Container container) { container.doLayout(); for (Component child : container.getComponents()) if (child instanceof Container) layout((Container)child); } +} diff --git a/src/test/java/com/osiris/autoplug/client/clientapp/NativeClientServiceTest.java b/src/test/java/com/osiris/autoplug/client/clientapp/NativeClientServiceTest.java new file mode 100644 index 00000000..81e6fd80 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/clientapp/NativeClientServiceTest.java @@ -0,0 +1,152 @@ +package com.osiris.autoplug.client.clientapp; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class NativeClientServiceTest { + @TempDir Path temp; + private Profile launched; + private ServerAddress joined; + private LaunchAccount account; + private int launches, applied; + private NativeClientService service(ProfileUpdater.Resolver resolver, boolean failLaunch) throws Exception { + ProfileStore profiles = new ProfileStore(temp.resolve("autoplug")); ClientSettings settings = new ClientSettings(profiles.getRoot()); + ProfileUpdater updater = new ProfileUpdater(profiles.getCache(), resolver, (url, destination) -> { throw new IOException("No network in this fixture"); }); + return new NativeClientService(profiles, new ServerCatalog(profiles.getRoot()), settings, (profile, login, server) -> { + if (failLaunch) throw new IOException("Fixture launch failure"); launched = profile; joined = server; account = login; launches++; return null; + }, new NativeClientService.Migration() { + public ProfileUpdater.Plan check(Profile profile) throws Exception { return updater.check(profile); } + public void apply(ProfileUpdater.Plan plan, boolean allowDisabling) throws Exception { updater.apply(plan, allowDisabling); applied++; } + }); + } + private ServerStatus status(String version) { return new ServerStatus(new SavedServer("World", ServerAddress.parse("localhost:25566"), "FABRIC"), true, "Welcome", version, 765, 0, 20, 1, ""); } + private static Path jar(Path path) throws Exception { try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(path))) { zip.putNextEntry(new ZipEntry("fabric.mod.json")); zip.write("{\"id\":\"fixture\",\"version\":\"1.0\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); zip.closeEntry(); } return path; } + @Test void exactMatchLaunchesSelectedClientWithServerAndNoMigrationOrPrompt() throws Exception { + NativeClientService service = service((profile, file) -> { throw new AssertionError("Should not migrate"); }, false); + service.profiles.create("plugins", Profile.Type.PLUGINS, "1.20.4", "paper", false); + service.profiles.create("client", Profile.Type.MODS, "1.20.4", "fabric", false); + service.autoPlay(status("Paper 1.20.4"), question -> { throw new AssertionError("Should not prompt"); }); + assertEquals(1, launches); assertEquals("client", launched.id); assertEquals(25566, joined.port); assertEquals("Player", account.getName()); assertEquals(0, applied); + } + @Test void mismatchClonesAndDisablesUnsupportedModOnlyAfterBothConfirmations() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + Profile template = service.profiles.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); Path original = jar(template.assetsDirectory().resolve("fixture.jar")); + AtomicInteger questions = new AtomicInteger(); + service.autoPlay(status("1.20.4"), question -> { int number = questions.incrementAndGet(); if (number == 2) assertTrue(question.contains("DISABLE fixture.jar")); return true; }); + assertEquals(2, questions.get()); assertEquals(1, applied); assertEquals(1, launches); assertFalse(launched.isTemplate); assertEquals("1.20.4", launched.gameVersion); + assertTrue(Files.exists(launched.assetsDirectory().resolve("fixture.jar.disabled"))); assertTrue(Files.exists(original)); assertEquals(2, service.profiles.list().size()); + } + @Test void decliningInitialCloneCreatesNothingAndNeverLaunches() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + service.profiles.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); + service.autoPlay(status("1.20.4"), question -> false); + assertEquals(1, service.profiles.list().size()); assertEquals(0, launches); assertEquals(0, applied); + } + @Test void decliningDisabledSummaryRemovesNewCloneAndLeavesTemplateIntact() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + Profile template = service.profiles.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); Path original = jar(template.assetsDirectory().resolve("fixture.jar")); + AtomicInteger questions = new AtomicInteger(); service.autoPlay(status("1.20.4"), question -> questions.incrementAndGet() == 1); + assertEquals(1, service.profiles.list().size()); assertTrue(Files.exists(original)); assertEquals(0, applied); assertEquals(0, launches); + } + @Test void providerFailureNeverDisablesOrLaunchesAndCleansUncommittedClone() throws Exception { + NativeClientService service = service((profile, file) -> { throw new IOException("Fixture API unavailable"); }, false); + Profile template = service.profiles.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); Path original = jar(template.assetsDirectory().resolve("fixture.jar")); + assertThrows(IOException.class, () -> service.autoPlay(status("1.20.4"), question -> true)); + assertTrue(Files.exists(original)); assertEquals(1, service.profiles.list().size()); assertEquals(0, applied); assertEquals(0, launches); + } + @Test void failedLaunchRemovesNewCloneAndReleasesPreparationGuardForRetry() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), true); + service.profiles.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); + for (int i = 0; i < 2; i++) assertEquals("Fixture launch failure", assertThrows(IOException.class, () -> service.autoPlay(status("1.20.4"), question -> true)).getMessage()); + assertEquals(1, service.profiles.list().size()); + } + @Test void templateLoaderMismatchAndPluginLaunchAreRejected() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + service.profiles.create("wrong", Profile.Type.MODS, "1.21.1", "forge", true); service.settings.templateId = "wrong"; + assertThrows(IOException.class, () -> service.autoPlay(status("1.20.4"), question -> true)); + service.profiles.create("hub", Profile.Type.PLUGINS, "1.20.4", "paper", false); assertThrows(IllegalArgumentException.class, () -> service.launch("hub", null)); assertEquals(0, launches); + } + @Test void cliLaunchSharesProfileAndExplicitServerWithoutTouchingGlobalServerState() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); service.profiles.create("client", Profile.Type.MODS, "1.20.4", "fabric", false); + List output = new ArrayList<>(); NativeClientCommands commands = new NativeClientCommands(service); + assertFalse(commands.handle(".stop", output::add)); assertTrue(commands.handle(".client launch client --server [::1]:25566", output::add)); + assertEquals("client", launched.id); assertEquals("::1", joined.host); assertEquals(25566, joined.port); assertEquals(1, launches); + commands.handle(".client launch client --server https://invalid", output::add); assertEquals(1, launches); assertTrue(output.get(output.size() - 1).startsWith("Client command failed:")); + } + @Test void publicPreferencesAreAtomicAndDoNotSerializeAccountTokens() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + service.settings.apply("Fixture", false, "12345678-1234-1234-1234-123456789abc", "", Collections.singletonMap(17, "java")); + byte[] before = Files.readAllBytes(service.profiles.getRoot().resolve("client-settings.properties")); + assertThrows(IllegalArgumentException.class, () -> service.settings.apply("INVALID NAME", true, "", "", Collections.emptyMap())); + assertEquals("Fixture", service.settings.offlineName); assertFalse(service.settings.offlineMode); assertArrayEquals(before, Files.readAllBytes(service.profiles.getRoot().resolve("client-settings.properties"))); + service.setAuthenticatedAccount(LaunchAccount.microsoft("Fixture", "12345678123412341234123456789abc", "DO_NOT_SAVE_ACCESS", "1", service.settings.microsoftClientId)); service.settings.save(); + assertFalse(new String(Files.readAllBytes(service.profiles.getRoot().resolve("client-settings.properties")), java.nio.charset.StandardCharsets.UTF_8).contains("DO_NOT_SAVE_ACCESS")); + service.signOut(); assertFalse(service.isSignedIn()); + service.profiles.create("client", Profile.Type.MODS, "1.20.4", "fabric", false); assertThrows(IllegalStateException.class, () -> service.launch("client", null)); + } + @Test void invalidCliOfflineAuthDoesNotChangePreviouslyValidMicrosoftModeOrSession() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + service.settings.apply("Fixture", false, "", "", Collections.emptyMap()); service.setAuthenticatedAccount(LaunchAccount.microsoft("Fixture", "12345678123412341234123456789abc", "FIXTURE_TOKEN", "1", "fixture-app")); + List output = new ArrayList<>(); new NativeClientCommands(service).handle(".client auth offline bad-name", output::add); + assertFalse(service.settings.offlineMode); assertEquals("Fixture", service.settings.offlineName); assertTrue(service.isSignedIn()); assertTrue(output.get(0).startsWith("Client command failed:")); + } + @Test void signoutOrReplacementAttemptRejectsLateAuthenticationCompletion() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); LaunchAccount account = LaunchAccount.microsoft("Fixture", "12345678123412341234123456789abc", "FIXTURE_TOKEN", "1", "fixture-app"); + long pending = service.beginAuthentication(); service.signOut(); assertFalse(service.completeAuthentication(pending, account, null)); assertFalse(service.isSignedIn()); + long previous = service.beginAuthentication(), current = service.beginAuthentication(); assertFalse(service.completeAuthentication(previous, account, null)); assertTrue(service.completeAuthentication(current, account, null)); assertTrue(service.isSignedIn()); + long cancelled = service.beginAuthentication(); service.cancelAuthentication(cancelled); assertFalse(service.completeAuthentication(cancelled, account, null)); + } + @Test void activeModsAndPluginsDefaultsPersistAndOptionalUpdateFormsRemainIsolated() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + Profile mods = service.profiles.create("mods", Profile.Type.MODS, "1.20.4", "fabric", false), plugins = service.profiles.create("plugins", Profile.Type.PLUGINS, "1.20.4", "paper", false); + ProfileUpdater updater = new ProfileUpdater(service.profiles.getCache(), (profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), (url, path) -> { throw new IOException("No network"); }); + ProfileCommands commands = new ProfileCommands(service.profiles, updater); List output = new ArrayList<>(); + assertFalse(commands.handle(".check mods", output::add)); assertTrue(commands.handle(".update mods", output::add)); assertTrue(output.get(0).contains("Select a default")); + commands.handle(".profiles activate mods", output::add); commands.handle(".profiles activate plugins", output::add); + ProfileStore reloaded = new ProfileStore(service.profiles.getRoot()); assertEquals(mods.id, reloaded.getActive(Profile.Type.MODS).id); assertEquals(plugins.id, reloaded.getActive(Profile.Type.PLUGINS).id); + jar(mods.assetsDirectory().resolve("mod.jar")); jar(plugins.assetsDirectory().resolve("plugin.jar")); + commands.handle(".check mods", output::add); assertTrue(Files.exists(mods.assetsDirectory().resolve("mod.jar"))); + commands.handle(".update mods --confirm-disabling", output::add); assertTrue(Files.exists(mods.assetsDirectory().resolve("mod.jar.disabled"))); assertTrue(Files.exists(plugins.assetsDirectory().resolve("plugin.jar"))); + commands.handle(".update plugins --confirm-disabling", output::add); assertTrue(Files.exists(plugins.assetsDirectory().resolve("plugin.jar.disabled"))); + service.profiles.delete("mods"); assertNull(service.profiles.getActive(Profile.Type.MODS)); assertEquals("plugins", service.profiles.getActive(Profile.Type.PLUGINS).id); + } + @Test void lateAuthenticationCannotOverwriteNewOfflinePreferencesAndValidCompletionPersistsAtomically() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + String clientId = "12345678-1234-1234-1234-123456789abc"; + LaunchAccount account = LaunchAccount.microsoft("Fixture", "12345678123412341234123456789abc", "FIXTURE_TOKEN", "1", clientId); + long cancelled = service.beginAuthentication(); assertTrue(service.authenticationCurrent(cancelled)); + service.useOfflineAccount("ChosenOffline"); + assertFalse(service.completeAuthenticationAndSave(cancelled, account, null, clientId)); + ClientSettings persisted = new ClientSettings(service.profiles.getRoot()); + assertTrue(service.settings.offlineMode); assertTrue(persisted.offlineMode); assertEquals("ChosenOffline", persisted.offlineName); assertFalse(service.isSignedIn()); + long current = service.beginAuthentication(); assertTrue(service.completeAuthenticationAndSave(current, account, null, clientId)); + persisted = new ClientSettings(service.profiles.getRoot()); assertFalse(persisted.offlineMode); assertEquals(clientId, persisted.microsoftClientId); assertTrue(service.isSignedIn()); + long invalid = service.beginAuthentication(); assertThrows(IllegalArgumentException.class, () -> service.completeAuthenticationAndSave(invalid, account, null, "invalid-id")); + assertFalse(service.authenticationCurrent(invalid)); assertEquals(clientId, new ClientSettings(service.profiles.getRoot()).microsoftClientId); + } + @Test void startupCommandTransportPreservesVersionsAndHostnamesAndRecognizesOnlyClientScope() { + List commands = StartupCommands.parse(Arrays.asList(".profiles", "create", "base", "1.20.4", "fabric", ".client", "launch", "base", "--server", "play.example.com:25566")); + assertEquals(Arrays.asList(".profiles create base 1.20.4 fabric", ".client launch base --server play.example.com:25566"), commands); assertTrue(StartupCommands.clientOnly(commands)); + assertFalse(StartupCommands.clientOnly(Collections.singletonList(".backup"))); assertFalse(StartupCommands.clientOnly(Collections.emptyList())); + } + @Test void worldNetworkPreferencesPersistAndInvalidCandidateDoesNotChangeAccountOrDisk() throws Exception { + NativeClientService service = service((profile, file) -> ProfileUpdater.Resolution.unsupported("fixture"), false); + service.applyPreferences("Fixture", false, "", "", Collections.emptyMap(), 25566, false); + service.setAuthenticatedAccount(LaunchAccount.microsoft("Fixture", "12345678123412341234123456789abc", "FIXTURE_TOKEN", "1", "fixture-app")); + byte[] before = Files.readAllBytes(service.profiles.getRoot().resolve("client-settings.properties")); + assertThrows(IllegalArgumentException.class, () -> service.applyPreferences("Changed", true, "", "", Collections.emptyMap(), 80, true)); + assertTrue(service.isSignedIn()); assertFalse(service.settings.offlineMode); assertEquals("Fixture", service.settings.offlineName); + assertArrayEquals(before, Files.readAllBytes(service.profiles.getRoot().resolve("client-settings.properties"))); + service.useOfflineAccount("FixtureLocal"); ClientSettings reloaded = new ClientSettings(service.profiles.getRoot()); + assertEquals(25566, reloaded.worldPort()); assertFalse(reloaded.upnpAllowed()); assertTrue(reloaded.offlineMode); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/LauncherCancellationTest.java b/src/test/java/com/osiris/autoplug/client/launcher/LauncherCancellationTest.java new file mode 100644 index 00000000..ad44f5c3 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/LauncherCancellationTest.java @@ -0,0 +1,34 @@ +package com.osiris.autoplug.client.launcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class LauncherCancellationTest { + @TempDir Path root; + @Test void cancelledPreparationDoesNotOpenTransportOrInstallArtifacts() throws Exception { + AtomicInteger opens = new AtomicInteger(); + LauncherDownloads downloads = new LauncherDownloads(root, uri -> { opens.incrementAndGet(); return new ByteArrayInputStream(new byte[]{1}); }); + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedIOException.class, () -> downloads.download("https://fixture.invalid/client.jar", "client.jar", null, "SHA-256", 1)); + assertThrows(InterruptedIOException.class, () -> downloads.json("https://fixture.invalid/metadata")); + assertEquals(0, opens.get()); assertFalse(Files.exists(root.resolve("client.jar"))); assertTrue(Thread.currentThread().isInterrupted()); + } finally { Thread.interrupted(); } + } + @Test void interruptionDuringReadRemovesPartialArtifactAndPreservesExistingTarget() throws Exception { + Path target = root.resolve("client.jar"); Files.write(target, new byte[]{7}); + LauncherDownloads downloads = new LauncherDownloads(root, uri -> new ByteArrayInputStream(new byte[]{1, 2}) { + public synchronized int read(byte[] buffer, int offset, int length) { int count = super.read(buffer, offset, length); Thread.currentThread().interrupt(); return count; } + }); + try { + assertThrows(InterruptedIOException.class, () -> downloads.download("https://fixture.invalid/client.jar", "client.jar", null, "SHA-256", 2)); + assertArrayEquals(new byte[]{7}, Files.readAllBytes(target)); + try (java.util.stream.Stream files = Files.list(root)) { assertEquals(1, files.count()); } + assertTrue(Thread.currentThread().isInterrupted()); + } finally { Thread.interrupted(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/LoaderInstallerTest.java b/src/test/java/com/osiris/autoplug/client/launcher/LoaderInstallerTest.java new file mode 100644 index 00000000..769bcd09 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/LoaderInstallerTest.java @@ -0,0 +1,95 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.tools.ToolProvider; +import java.io.*; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.jar.*; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class LoaderInstallerTest { + @TempDir Path temp; + static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { zip.putNextEntry(new ZipEntry(entry.getKey())); zip.write(entry.getValue()); zip.closeEntry(); } + } + return bytes.toByteArray(); + } + @Test void realForgeAndNeoForgeClientProcessorsResolveEveryOfficialArgument() throws Exception { + for (String kind : Arrays.asList("forge", "neoforge", "forge-26.3", "neoforge-26.3")) { + String minecraft = kind.endsWith("26.3") ? "26.3" : "1.20.4"; + JsonObject profile = NativeLauncherTest.fixture(kind + "-install_profile.json"); + Map entries = new HashMap<>(); entries.put("data/client.lzma", new byte[]{1,2,3}); + Path installerJar = temp.resolve(kind + "-installer.jar"); Files.write(installerJar, zip(entries)); + LauncherDownloads downloads = new LauncherDownloads(temp.resolve(kind + "-cache"), uri -> { throw new IOException("Fixture must not access a network"); }); + LoaderInstaller installer = new LoaderInstaller(downloads,new LauncherPlatform("linux","amd64","6"),major -> temp.resolve("java")); + Path root = downloads.resolve("work/.marker").getParent(); Files.createDirectories(root); + Map data = installer.installerData(profile,installerJar,root,temp.resolve("minecraft.jar"),minecraft); + assertEquals("client",data.get("SIDE")); assertTrue(data.get("PATCHED").startsWith(downloads.getRoot().toString())); + assertEquals(downloads.getRoot().resolve("libraries"), Paths.get(installer.expression("{ROOT}/libraries", data)).normalize()); + int clients = 0; + for (JsonElement p : profile.getAsJsonArray("processors")) { + JsonObject processor = p.getAsJsonObject(); if (!LoaderInstaller.runsOnClient(processor)) continue; clients++; + NativeLauncher.mavenPath(processor.get("jar").getAsString()); + for (JsonElement coordinate : processor.getAsJsonArray("classpath")) NativeLauncher.mavenPath(coordinate.getAsString()); + for (JsonElement argument : processor.getAsJsonArray("args")) { + String resolved = installer.expression(argument.getAsString(),data); assertFalse(resolved.matches(".*\\{[^}]+}.*")); + } + if (processor.has("outputs")) assertFalse(installer.outputsValid(processor,data)); + } + assertTrue(clients >= (minecraft.equals("26.3") ? 1 : 5), "Fixture must cover the actual declared client install chain"); + JsonObject metadata = NativeLauncher.merge(NativeLauncherTest.fixture(minecraft + ".json"),NativeLauncherTest.fixture(kind + "-version.json")); + LaunchPlan plan = new LaunchPlan(metadata,minecraft,temp.resolve("game"),temp.resolve("natives"),temp.resolve("assets"),downloads.getRoot().resolve("libraries"),temp.resolve("minecraft.jar"),Collections.singletonList(temp.resolve("minecraft.jar")),LaunchAccount.offline("Fixture"),new LauncherPlatform("linux","amd64","6"),null); + List command = plan.buildCommand(temp.resolve("java"),"localhost",25565); + assertFalse(command.stream().anyMatch(s -> s.contains("${"))); assertTrue(command.contains("--quickPlayMultiplayer")); + if (kind.equals("neoforge")) assertTrue(command.contains("ALL-MODULE-PATH")); + } + } + byte[] processorJar() throws IOException { + Path source = temp.resolve("FixtureProcessor.java"); + Files.write(source, ("import java.nio.file.*; public class FixtureProcessor { public static void main(String[] args)throws Exception { Path output=Paths.get(args[0]); Files.createDirectories(output.getParent()); Files.write(output,args[1].getBytes(\"UTF-8\")); } }").getBytes(StandardCharsets.UTF_8)); + assertNotNull(ToolProvider.getSystemJavaCompiler(),"Fixture subprocess requires the build JDK, never a Minecraft account"); + assertEquals(0,ToolProvider.getSystemJavaCompiler().run(null,null,null,"--release","9","-d",temp.toString(),source.toString())); + Manifest manifest = new Manifest(); manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION,"1.0"); manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS,"FixtureProcessor"); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(bytes,manifest)) { jar.putNextEntry(new JarEntry("FixtureProcessor.class")); jar.write(Files.readAllBytes(temp.resolve("FixtureProcessor.class"))); jar.closeEntry(); } + return bytes.toByteArray(); + } + @Test void completeInstallerFixtureRunsJavaProcessorChecksOutputAndReusesVerifiedInstallation() throws Exception { + byte[] processor = processorJar(), payload = "patched-fixture".getBytes(StandardCharsets.UTF_8); + String processorPath = NativeLauncher.mavenPath("fixture:processor:1"); String patchedPath = NativeLauncher.mavenPath("fixture:client:1"); + JsonObject processorArtifact = new JsonObject(); processorArtifact.addProperty("path",processorPath); processorArtifact.addProperty("url",""); processorArtifact.addProperty("sha1",NativeLauncherTest.sha1(processor)); processorArtifact.addProperty("size",processor.length); + JsonObject processorLibrary = new JsonObject(); processorLibrary.addProperty("name","fixture:processor:1"); JsonObject processorDownloads = new JsonObject(); processorDownloads.add("artifact",processorArtifact); processorLibrary.add("downloads",processorDownloads); + JsonObject generatedArtifact = new JsonObject(); generatedArtifact.addProperty("path",patchedPath); generatedArtifact.addProperty("url",""); generatedArtifact.addProperty("sha1",NativeLauncherTest.sha1(payload)); generatedArtifact.addProperty("size",payload.length); + JsonObject generatedLibrary = new JsonObject(); generatedLibrary.addProperty("name","fixture:client:1"); JsonObject generatedDownloads = new JsonObject(); generatedDownloads.add("artifact",generatedArtifact); generatedLibrary.add("downloads",generatedDownloads); + JsonObject profile = JsonParser.parseString("{spec:1,minecraft:'1.20.4',data:{PATCHED:{client:'[fixture:client:1]'}},processors:[{jar:'fixture:processor:1',classpath:[],args:['{PATCHED}','patched-fixture'],outputs:{'{PATCHED}':'"+NativeLauncherTest.sha1(payload)+"'}}]}").getAsJsonObject(); + JsonArray installationLibraries = new JsonArray(); installationLibraries.add(processorLibrary); profile.add("libraries",installationLibraries); + JsonObject version = JsonParser.parseString("{id:'1.20.4-forge-fixture',inheritsFrom:'1.20.4',mainClass:'fixture.Main'}").getAsJsonObject(); JsonArray versionLibraries = new JsonArray(); versionLibraries.add(generatedLibrary); version.add("libraries",versionLibraries); + Map entries = new LinkedHashMap<>(); entries.put("install_profile.json",profile.toString().getBytes(StandardCharsets.UTF_8)); entries.put("version.json",version.toString().getBytes(StandardCharsets.UTF_8)); entries.put("maven/"+processorPath,processor); byte[] archive = zip(entries); + AtomicInteger requests = new AtomicInteger(), javaResolutions = new AtomicInteger(); String installerHash = NativeLauncherTest.sha1(archive); + LauncherDownloads downloads = new LauncherDownloads(temp.resolve("cache"),uri -> { requests.incrementAndGet(); if(uri.toString().endsWith(".sha1")) return new ByteArrayInputStream(installerHash.getBytes(StandardCharsets.UTF_8)); if(uri.toString().endsWith("-installer.jar")) return new ByteArrayInputStream(archive); throw new IOException("Unexpected fixture request"); }); + Path java = Paths.get(System.getProperty("java.home"),"bin",LauncherPlatform.current().os.equals("windows")?"java.exe":"java"); + LoaderInstaller installer = new LoaderInstaller(downloads,LauncherPlatform.current(),major -> { javaResolutions.incrementAndGet(); return java; }); + Path client = temp.resolve("minecraft.jar"); Files.write(client,new byte[]{1}); + JsonObject actual = installer.install("1.20.4","FORGE","49.2.0",client,Runtime.version().major()); + assertEquals(version,actual); assertArrayEquals(payload,Files.readAllBytes(downloads.resolve("libraries/"+patchedPath))); assertEquals(1,javaResolutions.get()); + installer.install("1.20.4","FORGE","49.2.0",client,Runtime.version().major()); assertEquals(1,javaResolutions.get(),"Verified generated outputs should avoid re-running every processor"); + Files.write(downloads.resolve("libraries/"+patchedPath),new byte[]{0}); + installer.install("1.20.4","FORGE","49.2.0",client,Runtime.version().major()); assertEquals(2,javaResolutions.get()); assertArrayEquals(payload,Files.readAllBytes(downloads.resolve("libraries/"+patchedPath))); + assertEquals(4,requests.get(),"Only the first installer artifact and fresh checksum reads need the transport"); + } + @Test void processorOutputsValidateAllPathsBeforeExecutingAnyJar() throws Exception { + LauncherDownloads downloads = new LauncherDownloads(temp.resolve("cache"),uri -> { throw new IOException("No network"); }); LoaderInstaller installer = new LoaderInstaller(downloads,LauncherPlatform.current(),major -> temp.resolve("java")); + Map data = new HashMap<>(); data.put("ROOT",downloads.getRoot().toString()); data.put("OUTSIDE",temp.resolve("outside").toString()); + JsonObject processor = JsonParser.parseString("{jar:'fixture:processor:1',outputs:{'{ROOT}/missing.jar':'0000000000000000000000000000000000000000','{OUTSIDE}':'0000000000000000000000000000000000000000'}}").getAsJsonObject(); + assertThrows(IOException.class,()->installer.runProcessor(processor,data,temp.resolve("missing-java"),downloads.getRoot())); assertFalse(Files.exists(temp.resolve("outside"))); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntimeTest.java b/src/test/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntimeTest.java new file mode 100644 index 00000000..41866f90 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/ManagedJavaRuntimeTest.java @@ -0,0 +1,53 @@ +package com.osiris.autoplug.client.launcher; + +import org.apache.commons.compress.archivers.tar.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPOutputStream; +import static org.junit.jupiter.api.Assertions.*; + +class ManagedJavaRuntimeTest { + @TempDir Path temp; + @Test void releaseFilesReadExactJava8Java17Java21Java25Majors() throws Exception { + Path release = temp.resolve("release"); + for (String version : new String[]{"1.8.0_472","17.0.17","21.0.9","25.0.0"}) { + Files.write(release,("JAVA_VERSION=\""+version+"\"\n").getBytes(StandardCharsets.UTF_8)); + assertEquals(version.startsWith("1.")?8:Integer.parseInt(version.split("\\.")[0]),ManagedJavaRuntime.javaMajorFromRelease(release)); + } + assertEquals(-1,ManagedJavaRuntime.javaMajorFromRelease(temp.resolve("missing"))); + } + @Test void currentJavaAndConfiguredOverrideAreCheckedWithoutNetworkOrGameLaunch() throws Exception { + AtomicInteger requests = new AtomicInteger(); ManagedJavaRuntime runtime = new ManagedJavaRuntime(temp.resolve("runtime"),uri -> { requests.incrementAndGet(); throw new IOException("No network"); }); + int actual = Runtime.version().major(); Path java = Paths.get(System.getProperty("java.home"),"bin",LauncherPlatform.current().os.equals("windows")?"java.exe":"java"); + assertEquals(java,runtime.resolve(actual)); assertEquals(0,requests.get()); + runtime.configure(actual,java); assertEquals(java,runtime.resolve(actual)); + assertThrows(IOException.class,()->ManagedJavaRuntime.verifyMajor(java,actual==8?17:8)); + runtime.configure(21,temp.resolve("missing-java")); assertThrows(IOException.class,()->runtime.resolve(21)); assertEquals(0,requests.get()); + } + static void entry(TarArchiveOutputStream tar, String name, byte[] bytes) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name); entry.setSize(bytes.length); tar.putArchiveEntry(entry); tar.write(bytes); tar.closeArchiveEntry(); + } + @Test void runtimeTarExtractsFilesAndSafeLinksButNeverExternalLinkTargets() throws Exception { + Path archive = temp.resolve("runtime.tar.gz"); + try (TarArchiveOutputStream tar = new TarArchiveOutputStream(new GZIPOutputStream(Files.newOutputStream(archive)))) { + entry(tar,"jdk/lib/real.txt","runtime".getBytes(StandardCharsets.UTF_8)); + TarArchiveEntry link = new TarArchiveEntry("jdk/lib/alias.txt",TarConstants.LF_SYMLINK); link.setLinkName("real.txt"); tar.putArchiveEntry(link); tar.closeArchiveEntry(); + } + Path output = temp.resolve("safe"); ManagedJavaRuntime.extractTarGz(archive,output); + assertEquals("runtime",new String(Files.readAllBytes(output.resolve("jdk/lib/alias.txt")),StandardCharsets.UTF_8)); + Path malicious = temp.resolve("unsafe.tar.gz"); + try (TarArchiveOutputStream tar = new TarArchiveOutputStream(new GZIPOutputStream(Files.newOutputStream(malicious)))) { + TarArchiveEntry link = new TarArchiveEntry("jdk/link",TarConstants.LF_SYMLINK); link.setLinkName("../../outside"); tar.putArchiveEntry(link); tar.closeArchiveEntry(); + } + assertThrows(IOException.class,()->ManagedJavaRuntime.extractTarGz(malicious,temp.resolve("unsafe"))); assertFalse(Files.exists(temp.resolve("outside"))); + } + @Test void incompleteStagingRuntimeCannotBePickedAsAnInstalledRuntime() throws Exception { + Path root = temp.resolve("17"); Path staging = root.resolve(".staging-incomplete/jdk"); Files.createDirectories(staging.resolve("bin")); + Files.write(staging.resolve("release"),"JAVA_VERSION=\"17.0.17\"".getBytes(StandardCharsets.UTF_8)); Files.write(staging.resolve("bin/java"),new byte[]{0}); + assertNull(ManagedJavaRuntime.findExecutable(root,17,"java")); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftOAuthTest.java b/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftOAuthTest.java new file mode 100644 index 00000000..f76bdda4 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftOAuthTest.java @@ -0,0 +1,100 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class MicrosoftOAuthTest { + static final String CLIENT = "00000000-1111-2222-3333-444444444444"; + static final class TestClock extends Clock { + Instant instant = Instant.parse("2026-10-03T00:00:00Z"); + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return instant; } + void sleep(long millis) { instant = instant.plusMillis(millis); } + } + static final class Request { + final String method, url, body; final Map headers; + Request(String method, URI url, Map headers, byte[] body) { this.method = method; this.url = url.toString(); this.headers = headers; this.body = body == null ? "" : new String(body,StandardCharsets.UTF_8); } + } + static final class FixtureTransport implements MicrosoftOAuth.Transport { + final Queue responses = new ArrayDeque<>(); final List requests = new ArrayList<>(); + void add(int status, String json) { responses.add(new MicrosoftOAuth.Response(status, JsonParser.parseString(json).getAsJsonObject())); } + @Override public MicrosoftOAuth.Response request(String method, URI uri, Map headers, byte[] body) throws IOException { + requests.add(new Request(method, uri, headers, body)); MicrosoftOAuth.Response result = responses.poll(); + if(result == null) throw new IOException("Unexpected authentication request"); return result; + } + } + static void device(FixtureTransport transport) { transport.add(200,"{device_code:'FIXTURE-DEVICE-SECRET',user_code:'ABCD-EFGH',verification_uri:'https://www.microsoft.com/link',expires_in:900,interval:5}"); } + static void completeChain(FixtureTransport transport) { + transport.add(200,"{access_token:'FIXTURE-MS-ACCESS',refresh_token:'FIXTURE-MS-REFRESH',expires_in:3600}"); + transport.add(200,"{Token:'FIXTURE-XBOX-ACCESS'}"); + transport.add(200,"{Token:'FIXTURE-XSTS-ACCESS',DisplayClaims:{xui:[{uhs:'fixture-uhs',xid:'123456'}]}}"); + transport.add(200,"{access_token:'FIXTURE-MC-ACCESS',expires_in:7200}"); + transport.add(200,"{items:[{name:'game_minecraft'},{name:'product_minecraft'}]}"); + transport.add(200,"{id:'0123456789abcdef0123456789abcdef',name:'FixturePlayer'}"); + } + @Test void devicePendingSlowDownAndWholeXboxMinecraftChainUseOfficialProtocols() throws Exception { + FixtureTransport transport = new FixtureTransport(); device(transport); transport.add(400,"{error:'authorization_pending'}"); transport.add(400,"{error:'slow_down'}"); completeChain(transport); + TestClock clock = new TestClock(); MicrosoftOAuth oauth = new MicrosoftOAuth(CLIENT,transport,clock,clock::sleep); + MicrosoftOAuth.DeviceAuthorization device = oauth.beginDeviceAuthorization(); + assertEquals("ABCD-EFGH",device.getUserCode()); assertEquals(URI.create("https://www.microsoft.com/link"),device.getVerificationUri()); + assertFalse(device.toString().contains("FIXTURE-DEVICE")); + LaunchAccount account = oauth.awaitMinecraftAccount(device,()->false); + assertEquals("FixturePlayer",account.getName()); assertEquals("msa",account.getUserType()); assertEquals("123456",account.getXuid()); + assertEquals(CLIENT,account.getClientId()); assertEquals("FIXTURE-MC-ACCESS",account.getAccessToken()); + assertEquals("FIXTURE-MS-REFRESH",oauth.getRefreshToken()); assertEquals(Instant.parse("2026-10-03T02:00:20Z"),oauth.getAccessTokenExpiresAt()); + assertEquals(9,transport.requests.size()); Request authorization = transport.requests.get(0); + assertEquals("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",authorization.url); + assertTrue(authorization.body.contains("scope=XboxLive.signin+offline_access")); assertFalse(authorization.body.contains("client_secret")); + assertTrue(transport.requests.get(1).body.contains("device_code=FIXTURE-DEVICE-SECRET")); + JsonObject xbox = JsonParser.parseString(transport.requests.get(4).body).getAsJsonObject(); + assertEquals("d=FIXTURE-MS-ACCESS",xbox.getAsJsonObject("Properties").get("RpsTicket").getAsString()); + JsonObject xsts = JsonParser.parseString(transport.requests.get(5).body).getAsJsonObject(); + assertEquals("RETAIL",xsts.getAsJsonObject("Properties").get("SandboxId").getAsString()); + assertEquals("rp://api.minecraftservices.com/",xsts.get("RelyingParty").getAsString()); + JsonObject minecraft = JsonParser.parseString(transport.requests.get(6).body).getAsJsonObject(); + assertEquals("XBL3.0 x=fixture-uhs;FIXTURE-XSTS-ACCESS",minecraft.get("identityToken").getAsString()); + assertEquals("Bearer FIXTURE-MC-ACCESS",transport.requests.get(7).headers.get("Authorization")); + assertTrue(transport.requests.get(7).url.endsWith("/entitlements/mcstore")); assertTrue(transport.requests.get(8).url.endsWith("/minecraft/profile")); + assertFalse(oauth.toString().contains("FIXTURE")); oauth.clearSession(); assertNull(oauth.getRefreshToken()); assertNull(oauth.getAccessTokenExpiresAt()); + } + @Test void cancellationDeclinedAndExpiryAreTerminalAndDoNotStartXboxRequests() throws Exception { + FixtureTransport cancelled = new FixtureTransport(); device(cancelled); TestClock clock = new TestClock(); MicrosoftOAuth oauth = new MicrosoftOAuth(CLIENT,cancelled,clock,clock::sleep); + MicrosoftOAuth.DeviceAuthorization authorization = oauth.beginDeviceAuthorization(); + assertThrows(MicrosoftOAuth.AuthenticationException.class,()->oauth.awaitMinecraftAccount(authorization,()->true)); assertEquals(1,cancelled.requests.size()); + FixtureTransport declined = new FixtureTransport(); device(declined); declined.add(400,"{error:'authorization_declined',error_description:'DO-NOT-LOG-TOKEN'}"); + MicrosoftOAuth other = new MicrosoftOAuth(CLIENT,declined,clock,clock::sleep); MicrosoftOAuth.DeviceAuthorization second = other.beginDeviceAuthorization(); + IOException error = assertThrows(IOException.class,()->other.awaitMinecraftAccount(second,()->false)); assertFalse(error.getMessage().contains("DO-NOT-LOG")); assertEquals(2,declined.requests.size()); + FixtureTransport expired = new FixtureTransport(); expired.add(200,"{device_code:'SECRET',user_code:'A',verification_uri:'https://www.microsoft.com/link',expires_in:1,interval:5}"); + MicrosoftOAuth timeout = new MicrosoftOAuth(CLIENT,expired,clock,clock::sleep); MicrosoftOAuth.DeviceAuthorization third = timeout.beginDeviceAuthorization(); + assertThrows(IOException.class,()->timeout.awaitMinecraftAccount(third,()->false)); assertEquals(1,expired.requests.size()); + } + @Test void refreshRotatesTokenAndUsesNoDeviceFlow() throws Exception { + FixtureTransport transport = new FixtureTransport(); completeChain(transport); TestClock clock = new TestClock(); MicrosoftOAuth oauth = new MicrosoftOAuth(CLIENT,transport,clock,clock::sleep); + assertEquals("FixturePlayer",oauth.refresh("PREVIOUS-REFRESH").getName()); assertEquals("FIXTURE-MS-REFRESH",oauth.getRefreshToken()); + assertEquals(6,transport.requests.size()); assertTrue(transport.requests.get(0).url.endsWith("/token")); assertTrue(transport.requests.get(0).body.contains("grant_type=refresh_token")); + assertTrue(transport.requests.get(0).body.contains("refresh_token=PREVIOUS-REFRESH")); assertFalse(transport.requests.get(0).body.contains("client_secret")); + } + @Test void accountWithoutEntitlementIsRejectedBeforeProfileLookup() throws Exception { + FixtureTransport transport = new FixtureTransport(); transport.add(200,"{access_token:'MS'}"); transport.add(200,"{Token:'XBOX'}"); transport.add(200,"{Token:'XSTS',DisplayClaims:{xui:[{uhs:'hash'}]}}"); transport.add(200,"{access_token:'MC'}"); transport.add(200,"{items:[]}"); + TestClock clock = new TestClock(); MicrosoftOAuth oauth = new MicrosoftOAuth(CLIENT,transport,clock,clock::sleep); + IOException error = assertThrows(IOException.class,()->oauth.refresh("REFRESH")); assertTrue(error.getMessage().contains("entitlement")); assertEquals(5,transport.requests.size()); assertNull(oauth.getRefreshToken()); + } + @Test void xboxFamilyRestrictionHasActionableSecretFreeError() throws Exception { + FixtureTransport transport = new FixtureTransport(); transport.add(200,"{access_token:'MS-TOKEN'}"); transport.add(200,"{Token:'XBOX-TOKEN'}"); transport.add(401,"{XErr:2148916238,Message:'SECRET-USER-DETAILS'}"); + TestClock clock = new TestClock(); MicrosoftOAuth oauth = new MicrosoftOAuth(CLIENT,transport,clock,clock::sleep); + IOException error = assertThrows(IOException.class,()->oauth.refresh("REFRESH")); assertTrue(error.getMessage().contains("family")); assertFalse(error.getMessage().contains("SECRET")); + } + @Test void deviceFlowCannotCrossClientContextsAndHttpsTransportRejectsPlaintext() throws Exception { + FixtureTransport transport = new FixtureTransport(); device(transport); TestClock clock = new TestClock(); MicrosoftOAuth first = new MicrosoftOAuth(CLIENT,transport,clock,clock::sleep); + MicrosoftOAuth.DeviceAuthorization device = first.beginDeviceAuthorization(); MicrosoftOAuth other = new MicrosoftOAuth("another-client-id",transport,clock,clock::sleep); + assertThrows(IOException.class,()->other.awaitMinecraftAccount(device,()->false)); assertEquals(1,transport.requests.size()); + assertThrows(IOException.class,()->new MicrosoftOAuth.HttpsTransport().request("POST",URI.create("http://localhost/auth"),Collections.emptyMap(),null)); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/NativeLauncherTest.java b/src/test/java/com/osiris/autoplug/client/launcher/NativeLauncherTest.java new file mode 100644 index 00000000..af3c7507 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/NativeLauncherTest.java @@ -0,0 +1,145 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.*; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class NativeLauncherTest { + @TempDir Path temp; + static JsonObject fixture(String file) throws IOException { + try (Reader reader = new InputStreamReader(Objects.requireNonNull(NativeLauncherTest.class.getResourceAsStream("/native-launcher/" + file)), StandardCharsets.UTF_8)) { return JsonParser.parseReader(reader).getAsJsonObject(); } + } + LaunchPlan plan(JsonObject metadata, String version, LauncherPlatform platform, LaunchAccount account) { + Path directory = temp.resolve("profile with spaces"); Path client = temp.resolve("minecraft.jar"); + return new LaunchPlan(metadata, version, directory, temp.resolve("natives"), temp.resolve("assets"), temp.resolve("libraries"), client, Arrays.asList(temp.resolve("library.jar"), client), account, platform, null); + } + @ParameterizedTest @ValueSource(strings = {"windows", "linux", "osx"}) + void realModernMojangArgumentsKeepPathsIntactAndDirectConnect(String os) throws Exception { + LaunchPlan launch = plan(fixture("1.20.4.json"), "1.20.4", new LauncherPlatform(os, "amd64", "10.0"), LaunchAccount.offline("TestPlayer")); + List command = launch.buildCommand(temp.resolve("java with spaces/bin/java"), "localhost", 25566); + assertEquals(17, launch.getJavaMajor()); assertEquals(temp.resolve("java with spaces/bin/java").toString(), command.get(0)); + assertEquals("localhost:25566", command.get(command.indexOf("--quickPlayMultiplayer") + 1)); + assertFalse(command.contains("--server")); assertFalse(command.contains("--port")); + assertEquals(temp.resolve("profile with spaces").toString(), command.get(command.indexOf("--gameDir") + 1)); + assertEquals("TestPlayer", command.get(command.indexOf("--username") + 1)); + assertFalse(command.stream().anyMatch(s -> s.contains("${"))); assertFalse(command.contains("--demo")); + assertEquals(os.equals("osx"), command.contains("-XstartOnFirstThread")); + assertEquals(os.equals("windows"), command.stream().anyMatch(s -> s.startsWith("-XX:HeapDumpPath="))); + assertEquals("net.minecraft.client.main.Main", command.get(command.indexOf("-cp") + 2)); + } + @Test void realLegacyMetadataUsesJava8AndAuthenticationArguments() throws Exception { + LaunchPlan launch = plan(fixture("1.12.2.json"), "1.12.2", new LauncherPlatform("linux", "amd64", "6.0"), LaunchAccount.offline("Legacy")); + List command = launch.buildCommand(temp.resolve("java"), "127.0.0.1", 25565); + assertEquals(8, launch.getJavaMajor()); assertTrue(command.contains("--server")); + assertEquals("0", command.get(command.indexOf("--accessToken") + 1)); assertTrue(command.contains("-Djava.library.path=" + temp.resolve("natives"))); + } + @Test void minecraft1194StillUsesLegacyServerPortAndJava17() throws Exception { + LaunchPlan launch = plan(fixture("1.19.4.json"), "1.19.4", new LauncherPlatform("linux", "amd64", "6.0"), LaunchAccount.offline("Legacy")); + List command = launch.buildCommand(temp.resolve("java"), "localhost", 25566); + assertEquals(17, launch.getJavaMajor()); assertEquals("localhost", command.get(command.indexOf("--server")+1)); + assertEquals("25566", command.get(command.indexOf("--port")+1)); assertFalse(command.contains("--quickPlayMultiplayer")); + } + @Test void modernJava21QuickPlayAvoidsRemovedArgumentsAndSupportsIpv6() throws Exception { + LaunchAccount account = LaunchAccount.microsoft("OwnedPlayer", "123456781234123412341234567890ab", "SECRET-TOKEN", "1234", "client-id"); + LaunchPlan launch = plan(fixture("1.20.5.json"), "1.20.5", new LauncherPlatform("linux", "amd64", "6.0"), account); + List command = launch.buildCommand(temp.resolve("java"), "::1", 25565); + assertEquals(21, launch.getJavaMajor()); assertFalse(command.contains("--server")); assertFalse(command.contains("--port")); + assertEquals("[::1]:25565", command.get(command.indexOf("--quickPlayMultiplayer") + 1)); + assertEquals("SECRET-TOKEN", command.get(command.indexOf("--accessToken") + 1)); assertFalse(account.toString().contains("SECRET")); + assertEquals(1, command.stream().filter("--quickPlayMultiplayer"::equals).count()); + } + @Test void publicLoaderMetadataMergesMainClassAndRetainsVanillaAssets() throws Exception { + JsonObject vanilla = fixture("1.20.4.json"); + for (String loader : Arrays.asList("fabric", "quilt")) { + JsonObject merged = NativeLauncher.merge(vanilla, fixture(loader + "-1.20.4.json")); + assertEquals("1.20.4", merged.get("jar").getAsString()); assertEquals(vanilla.get("assetIndex"), merged.get("assetIndex")); + assertEquals(vanilla.get("downloads"), merged.get("downloads")); + LaunchPlan launch = plan(merged, "1.20.4", new LauncherPlatform("linux", "amd64", "6"), LaunchAccount.offline("Player")); + List command = launch.buildCommand(temp.resolve("java"), "example.test", 25565); + assertTrue(command.contains(loader.equals("fabric") ? "net.fabricmc.loader.impl.launch.knot.KnotClient" : "org.quiltmc.loader.impl.launch.knot.KnotClient")); + assertEquals(17, launch.getJavaMajor()); + } + } + @Test void orderedRulesHandleArchitectureRegexAndFeatures() { + LauncherPlatform windows = new LauncherPlatform("windows", "x86", "10.0"); + JsonObject rule = JsonParser.parseString("{rules:[{action:'allow'},{action:'disallow',os:{name:'windows'}},{action:'allow',os:{name:'windows',arch:'x86',version:'10\\\\..*'},features:{is_demo_user:true}}]}").getAsJsonObject(); + assertFalse(windows.allows(rule)); assertTrue(windows.allows(rule, Collections.singletonMap("is_demo_user", true))); + assertTrue(new LauncherPlatform("linux", "amd64", "6").allows(rule)); + assertFalse(windows.allows(JsonParser.parseString("{rules:[]}").getAsJsonObject())); + assertTrue(windows.allows(new JsonObject())); + } + @Test void verifiedCacheRejectsCorruptDownloadAndDoesNotOverwriteGoodBytes() throws Exception { + byte[] original = "verified library".getBytes(StandardCharsets.UTF_8); AtomicInteger requests = new AtomicInteger(); + LauncherDownloads cache = new LauncherDownloads(temp.resolve("cache"), uri -> { requests.incrementAndGet(); return new ByteArrayInputStream(original); }); + String hash = sha1(original); Path file = cache.download("https://official.test/library", "libraries/test.jar", hash, "SHA-1", original.length); + assertEquals(1, requests.get()); assertEquals(file, cache.download("https://official.test/library", "libraries/test.jar", hash, "SHA-1", original.length)); assertEquals(1, requests.get()); + LauncherDownloads corrupt = new LauncherDownloads(temp.resolve("cache"), uri -> new ByteArrayInputStream(new byte[]{0,1})); + assertThrows(IOException.class, () -> corrupt.download("https://official.test/library", "libraries/test.jar", "0000000000000000000000000000000000000000", "SHA-1", 2)); + assertArrayEquals(original, Files.readAllBytes(file)); + try (java.util.stream.Stream files = Files.walk(temp.resolve("cache"))) { assertFalse(files.anyMatch(p -> p.getFileName().toString().contains(".part-"))); } + } + @Test void metadataPathsMavenAndNativeZipCannotEscapeDirectories() throws Exception { + Path root = temp.resolve("cache"); Files.createDirectories(root); + for (String bad : Arrays.asList("../outside", "/tmp/outside", "C:/outside", "foo\\bar", "", ".", "../../x")) assertThrows(IOException.class, () -> LauncherDownloads.safeResolve(root, bad)); + assertEquals("org/example/lib/1.2/lib-1.2-natives-windows.jar", NativeLauncher.mavenPath("org.example:lib:1.2:natives-windows")); + assertEquals("org/example/lib/1.2/lib-1.2.zip", NativeLauncher.mavenPath("org.example:lib:1.2@zip")); + assertThrows(IOException.class, () -> NativeLauncher.mavenPath("a:../x:1")); + Path zip = temp.resolve("malicious.zip"); try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zip))) { out.putNextEntry(new ZipEntry("../outside")); out.write(1); out.closeEntry(); } + assertThrows(IOException.class, () -> LauncherDownloads.extractZip(zip, root, Collections.emptyList())); assertFalse(Files.exists(temp.resolve("outside"))); + } + @Test void prepareDownloadsClientAssetsLibrariesAndLegacyNativesFromFixtureProtocol() throws Exception { + Map bytes = new HashMap<>(); bytes.put("https://fixture/client.jar", "client".getBytes(StandardCharsets.UTF_8)); + bytes.put("https://fixture/lib.jar", "library".getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream archive = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(archive)) { zip.putNextEntry(new ZipEntry("test-native.so")); zip.write(new byte[]{1,2,3}); zip.closeEntry(); zip.putNextEntry(new ZipEntry("META-INF/notice")); zip.write(1); zip.closeEntry(); } + bytes.put("https://fixture/native.jar", archive.toByteArray()); byte[] asset = "sound bytes".getBytes(StandardCharsets.UTF_8); String assetHash = sha1(asset); + bytes.put("https://resources.download.minecraft.net/" + assetHash.substring(0,2) + "/" + assetHash, asset); + JsonObject object = new JsonObject(); object.addProperty("hash", assetHash); object.addProperty("size", asset.length); + JsonObject objects = new JsonObject(); objects.add("sounds/test.ogg", object); JsonObject index = new JsonObject(); index.add("objects", objects); index.addProperty("virtual", true); index.addProperty("map_to_resources", true); + bytes.put("https://fixture/index.json", index.toString().getBytes(StandardCharsets.UTF_8)); + JsonObject metadata = fixture("1.12.2.json"); JsonObject downloads = new JsonObject(); downloads.add("client", artifact("https://fixture/client.jar", null, bytes)); metadata.add("downloads", downloads); + JsonObject assetIndex = artifact("https://fixture/index.json", null, bytes); assetIndex.addProperty("id", "fixture"); metadata.add("assetIndex", assetIndex); metadata.remove("logging"); + JsonObject library = new JsonObject(); library.addProperty("name", "fixture:lib:1"); JsonObject libraryDownloads = new JsonObject(); libraryDownloads.add("artifact", artifact("https://fixture/lib.jar", "fixture/lib/1/lib-1.jar", bytes)); + JsonObject classifiers = new JsonObject(); classifiers.add("natives-linux-64", artifact("https://fixture/native.jar", "fixture/lib/1/lib-1-natives.jar", bytes)); libraryDownloads.add("classifiers", classifiers); library.add("downloads", libraryDownloads); + JsonObject nativeNames = new JsonObject(); nativeNames.addProperty("linux", "natives-linux-${arch}"); library.add("natives", nativeNames); JsonArray libraries = new JsonArray(); libraries.add(library); metadata.add("libraries", libraries); + AtomicInteger count = new AtomicInteger(); NativeLauncher launcher = new NativeLauncher(temp.resolve("cache"), uri -> { count.incrementAndGet(); byte[] value = bytes.get(uri.toString()); if (value == null) throw new IOException("Unexpected fixture request"); return new ByteArrayInputStream(value); }, new LauncherPlatform("linux", "amd64", "6"), major -> temp.resolve("java")); + LaunchPlan prepared = launcher.prepareMetadata(metadata, "1.12.2", temp.resolve("game"), LaunchAccount.offline("Fixture")); + assertEquals(5, count.get()); assertEquals(2, prepared.getClasspath().size()); assertTrue(Files.exists(temp.resolve("game/autoplug/natives/1.12.2-linux-amd64/test-native.so"))); + assertFalse(Files.exists(temp.resolve("game/autoplug/natives/1.12.2-linux-amd64/META-INF/notice"))); assertArrayEquals(asset, Files.readAllBytes(temp.resolve("game/resources/sounds/test.ogg"))); + assertArrayEquals(asset, Files.readAllBytes(temp.resolve("cache/assets/virtual/fixture/sounds/test.ogg"))); + launcher.prepareMetadata(metadata, "1.12.2", temp.resolve("game"), LaunchAccount.offline("Fixture")); assertEquals(5, count.get()); + } + @Test void forgeProcessorSidesNeoForgeSelectionAndXmlSecurity() throws Exception { + assertTrue(LoaderInstaller.runsOnClient(JsonParser.parseString("{sides:['client'],jar:'a:b:1'}").getAsJsonObject())); + assertFalse(LoaderInstaller.runsOnClient(JsonParser.parseString("{sides:['server'],jar:'a:b:1'}").getAsJsonObject())); + assertEquals("21.1.10", LoaderInstaller.selectNeoForgeVersion("21.1.221.1.1021.2.1", "1.21.1")); + assertThrows(Exception.class, () -> LoaderInstaller.selectNeoForgeVersion("]>&x;", "1.21.1")); + } + @Test void currentYearBasedMinecraftVersionUsesJava25QuickPlayAndActualNeoForgeNaming() throws Exception { + JsonObject metadata = fixture("26.3.json"); LaunchPlan launch = plan(metadata,"26.3",new LauncherPlatform("linux","amd64","6"),LaunchAccount.offline("Fixture")); + List command = launch.buildCommand(temp.resolve("java"),"localhost",25565); + assertEquals(25,launch.getJavaMajor()); assertTrue(command.contains("--quickPlayMultiplayer")); assertFalse(command.contains("--server")); + String xml = "26.2.0.4-beta26.3.0.45-beta26.3.0.46-beta26.3.1.50-beta"; + assertEquals("26.3.0.46-beta",LoaderInstaller.selectNeoForgeVersion(xml,"26.3")); + assertEquals("26.3.1.50-beta",LoaderInstaller.selectNeoForgeVersion(xml,"26.3.1")); + } + @Test void invalidHostPortAndAccountFailBeforeProcessCreation() throws Exception { + LaunchPlan launch = plan(fixture("1.20.4.json"), "1.20.4", new LauncherPlatform("linux", "amd64", "6"), LaunchAccount.offline("Player")); + assertThrows(IllegalArgumentException.class, () -> launch.buildCommand(temp.resolve("java"), "-malicious", 25565)); + assertThrows(IllegalArgumentException.class, () -> launch.buildCommand(temp.resolve("java"), "localhost", 0)); + assertThrows(IllegalArgumentException.class, () -> LaunchAccount.offline("invalid name")); + assertThrows(IOException.class, () -> launch.launch(temp.resolve("missing-java"), "localhost", 25565)); + } + static String sha1(byte[] bytes) throws Exception { StringBuilder result = new StringBuilder(); for (byte b : MessageDigest.getInstance("SHA-1").digest(bytes)) result.append(String.format("%02x", b & 255)); return result.toString(); } + static JsonObject artifact(String url, String path, Map bytes) throws Exception { JsonObject result = new JsonObject(); result.addProperty("url", url); result.addProperty("sha1", sha1(bytes.get(url))); result.addProperty("size", bytes.get(url).length); if (path != null) result.addProperty("path", path); return result; } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientLaunchTest.java b/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientLaunchTest.java new file mode 100644 index 00000000..9b4bea99 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientLaunchTest.java @@ -0,0 +1,64 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.tools.ToolProvider; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.jar.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Executes an original tiny offline fixture JAR, never Minecraft, Microsoft sign-in or a real server. */ +class OfflineClientLaunchTest { + @TempDir Path temp; + @Test void fullPrepareAndProcessBuilderLaunchPreserveOfflineIdentityDirectConnectAndIsolation() throws Exception { + Path source = temp.resolve("OfflineFixtureClient.java"); + String code = "import java.nio.file.*; import java.util.*; import java.io.*; " + + "public class OfflineFixtureClient { public static void main(String[] args)throws Exception { " + + "Properties p=new Properties(); p.setProperty(\"workingDirectory\",System.getProperty(\"user.dir\")); " + + "for(int i=0;i responses = new HashMap<>(); responses.put("https://fixture.test/client.jar",client); + JsonObject metadata = NativeLauncherTest.fixture("1.20.4.json"); metadata.addProperty("mainClass","OfflineFixtureClient"); + JsonObject clientArtifact = NativeLauncherTest.artifact("https://fixture.test/client.jar",null,responses); + JsonObject downloads = new JsonObject(); downloads.add("client",clientArtifact); metadata.add("downloads",downloads); + metadata.add("libraries",new JsonArray()); metadata.remove("assetIndex"); metadata.remove("logging"); + byte[] metadataBytes = metadata.toString().getBytes(StandardCharsets.UTF_8); responses.put("https://fixture.test/1.20.4.json",metadataBytes); + JsonObject entry = new JsonObject(); entry.addProperty("id","1.20.4"); entry.addProperty("url","https://fixture.test/1.20.4.json"); entry.addProperty("sha1",NativeLauncherTest.sha1(metadataBytes)); + JsonArray versions = new JsonArray(); versions.add(entry); JsonObject manifest = new JsonObject(); manifest.add("versions",versions); + responses.put(NativeLauncher.VERSION_MANIFEST,manifest.toString().getBytes(StandardCharsets.UTF_8)); + List requests = new ArrayList<>(); + NativeLauncher launcher = new NativeLauncher(temp.resolve("shared cache"),uri -> { + requests.add(uri.toString()); byte[] bytes = responses.get(uri.toString()); + if(bytes==null)throw new IOException("Unexpected offline fixture request"); return new ByteArrayInputStream(bytes); + },LauncherPlatform.current(),major -> Paths.get(System.getProperty("java.home"),"bin",LauncherPlatform.current().os.equals("windows")?"java.exe":"java")); + Path profile = temp.resolve("isolated profile with spaces"); LaunchAccount account = LaunchAccount.offline("OfflineFixture"); + LaunchPlan prepared = launcher.prepare("1.20.4","VANILLA",profile,account); + Path java = launcher.resolveJava(prepared); ManagedJavaRuntime.verifyMajor(java,Runtime.version().major()); + Process child = prepared.launch(java,"localhost",25566); + try { assertTrue(child.waitFor(10,TimeUnit.SECONDS),"Offline fixture process must terminate"); assertEquals(0,child.exitValue()); } + finally { if(child.isAlive())child.destroyForcibly(); } + assertEquals(Arrays.asList(NativeLauncher.VERSION_MANIFEST,"https://fixture.test/1.20.4.json","https://fixture.test/client.jar"),requests); + Properties record = new Properties(); try(InputStream in=Files.newInputStream(profile.resolve("launch-record.properties"))){record.load(in);} + assertEquals(profile.toString(),record.getProperty("workingDirectory")); List arguments = new ArrayList<>(); + for(int i=0;record.containsKey("arg."+i);i++)arguments.add(record.getProperty("arg."+i)); + assertEquals("OfflineFixture",arguments.get(arguments.indexOf("--username")+1)); + assertEquals(account.getUuid(),arguments.get(arguments.indexOf("--uuid")+1)); assertEquals("0",arguments.get(arguments.indexOf("--accessToken")+1)); + assertEquals(profile.toString(),arguments.get(arguments.indexOf("--gameDir")+1)); + assertEquals("localhost:25566",arguments.get(arguments.indexOf("--quickPlayMultiplayer")+1)); + assertFalse(Files.exists(temp.resolve("launch-record.properties"))); assertFalse(arguments.contains("--server")); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderLiveSmoke.java b/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderLiveSmoke.java new file mode 100644 index 00000000..c08b2d15 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderLiveSmoke.java @@ -0,0 +1,29 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.tasks.updater.mods.ModrinthAPI; +import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import com.osiris.autoplug.client.tasks.updater.search.bukkit.BukkitSearchById; +import java.time.Instant; +import java.util.Collections; + +/** Explicit opt-in production-transport smoke. Only public metadata GETs; no artifact downloads, launches or accounts. */ +public final class ProfileProviderLiveSmoke { + public static void main(String[] args) throws Exception { + ModrinthAPI api = new ModrinthAPI(); + JsonObject compatible = api.compatibleProfileRelease("P7dR8mSH", Collections.singletonList("fabric"), "1.20.1", null); + if (compatible == null || !"P7dR8mSH".equals(compatible.get("project_id").getAsString())) throw new IllegalStateException("No actual compatible Fabric API release"); + JsonObject pinned = api.compatibleProfileRelease("P7dR8mSH", Collections.singletonList("fabric"), "1.20.1", compatible.get("id").getAsString()); + if (pinned == null || !pinned.get("id").equals(compatible.get("id"))) throw new IllegalStateException("Pinned release validation failed"); + SearchResult bukkit = new BukkitSearchById().searchCompatible(new MinecraftPlugin("not-read-or-written.jar", "WorldEdit", "0", "", 0, 31043, null), "1.20.1"); + if (bukkit.isError()) throw new IllegalStateException("Actual Bukkit compatible metadata lookup failed", bukkit.exception); + if (bukkit.type == SearchResult.Type.RESOURCE_NOT_FOUND) throw new IllegalStateException("No actual compatible WorldEdit release"); + if (bukkit.downloadUrl == null || bukkit.fileName == null || bukkit.expectedMD5 == null) throw new IllegalStateException("Missing actual Bukkit artifact identity/checksum"); + System.out.println("PASS production Java public metadata transports at " + Instant.now()); + System.out.println("Modrinth project=" + compatible.get("project_id").getAsString() + " version=" + compatible.get("id").getAsString() + " target=1.20.1/fabric; pinned exact match"); + System.out.println("Bukkit project=31043 target=1.20.1 artifact=" + bukkit.fileName + "; MD5 supplied"); + System.out.println("No artifact downloaded; no account, game or global server configuration accessed"); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderTest.java new file mode 100644 index 00000000..b029ff5f --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProfileProviderTest.java @@ -0,0 +1,90 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.*; +import com.osiris.autoplug.client.tasks.updater.mods.ModrinthAPI; +import com.osiris.autoplug.client.tasks.updater.mods.CurseForgeAPI; +import com.osiris.autoplug.client.tasks.updater.plugins.InstalledPluginLoader; +import com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProfileProviderTest { + @TempDir Path directory; + private String version(String id, String game, String loader, String date) { + return "{\"id\":\"" + id + "\",\"project_id\":\"project\",\"version_number\":\"" + id + "\",\"game_versions\":[\"" + game + "\"],\"loaders\":[\"" + loader + "\"],\"date_published\":\"" + date + "\",\"files\":[{\"primary\":true,\"filename\":\"mod.jar\",\"url\":\"https://cdn.modrinth.com/data/project/mod.jar\",\"hashes\":{\"sha1\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}],\"dependencies\":[]}"; + } + @Test void selectsExactTargetAndLoaderInsteadOfNewerIncompatibleRelease() throws Exception { + try (FixtureServer server = new FixtureServer("[" + version("new", "1.21.1", "fabric", "2026-09-01T00:00:00Z") + "," + version("old", "1.20.1", "fabric", "2024-01-01T00:00:00Z") + "]", 200)) { + JsonObject selected = new ModrinthAPI(server.url()).compatibleProfileRelease("project", Collections.singletonList("fabric"), "1.20.1", null); + assertEquals("old", selected.get("id").getAsString()); + assertTrue(server.request.contains("game_versions=")); assertTrue(server.request.contains("loaders=")); + } + } + @Test void emptySuccessfulReleaseListMeansUnsupportedBut503Throws() throws Exception { + try (FixtureServer empty = new FixtureServer("[]", 200)) { assertNull(new ModrinthAPI(empty.url()).compatibleProfileRelease("project", Collections.singletonList("fabric"), "1.20.1", null)); } + try (FixtureServer failed = new FixtureServer("{\"error\":\"unavailable\"}", 503)) { assertThrows(IOException.class, () -> new ModrinthAPI(failed.url()).compatibleProfileRelease("project", Collections.singletonList("fabric"), "1.20.1", null)); } + } + @Test void pinnedDependencyChecksLoaderAndGameCompatibility() throws Exception { + try (FixtureServer server = new FixtureServer(version("pinned", "1.20.1", "forge", "2024-01-01T00:00:00Z"), 200)) { + assertNull(new ModrinthAPI(server.url()).compatibleProfileRelease("project", Collections.singletonList("fabric"), "1.20.1", "pinned")); + } + } + @Test void exactFileLookup404IsUnknownAndMalformedResponseIsError() throws Exception { + Path file = directory.resolve("mod.jar"); Files.write(file, Collections.singletonList("fixture")); + try (FixtureServer unknown = new FixtureServer("{}", 404)) { assertNull(new ModrinthAPI(unknown.url()).identifyProfileFile(file)); assertTrue(unknown.request.contains("algorithm=sha1")); } + try (FixtureServer malformed = new FixtureServer("not JSON", 200)) { assertThrows(IOException.class, () -> new ModrinthAPI(malformed.url()).identifyProfileFile(file)); } + } + @Test void injectedProfileResolverUsesExistingResourceFinder() throws Exception { + Path file = directory.resolve("mod.jar"); Files.write(file, Collections.singletonList("fixture")); + Profile profile = new Profile("profile", Profile.Type.MODS, "1.20.1", "fabric", directory, false); + int[] calls = {0}; + ModrinthAPI api = new ModrinthAPI() { + @Override public JsonObject identifyProfileFile(Path installed) { return JsonParser.parseString("{\"project_id\":\"project\"}").getAsJsonObject(); } + }; + ResourceFinder finder = new ResourceFinder() { + @Override public JsonObject findProfileRelease(ModrinthAPI ignored, String project, List loaders, String game, String pinned) { + calls[0]++; assertEquals("1.20.1", game); assertEquals(Collections.singletonList("fabric"), loaders); + return JsonParser.parseString(version("compatible", game, "fabric", "2024-01-01T00:00:00Z")).getAsJsonObject(); + } + }; + ProfileUpdater.Resolution result = new AutoPlugProfileResolver(api, finder).resolve(profile, file); + assertTrue(result.compatible); assertEquals(1, calls[0]); assertEquals("compatible", result.versionId); + } + @Test void distinguishesNeoForgeFromForgeAndOldTargetFromNewerRelease() { + JsonArray files = JsonParser.parseString("[{\"id\":1,\"gameVersions\":[\"1.20.1\",\"Forge\"],\"fileDate\":\"2024-01-01T00:00:00Z\"},{\"id\":2,\"gameVersions\":[\"1.21.1\",\"Forge\"],\"fileDate\":\"2026-01-01T00:00:00Z\"},{\"id\":3,\"gameVersions\":[\"1.20.1\",\"NeoForge\"],\"fileDate\":\"2025-01-01T00:00:00Z\"}]").getAsJsonArray(); + assertEquals(1, CurseForgeAPI.selectCompatibleProfileRelease(files, "FORGE", "1.20.1").get("id").getAsInt()); + assertEquals(3, CurseForgeAPI.selectCompatibleProfileRelease(files, "NEOFORGE", "1.20.1").get("id").getAsInt()); + assertNull(CurseForgeAPI.selectCompatibleProfileRelease(files, "FABRIC", "1.20.1")); + } + @Test void bukkitAndSpigotTargetsNeverAcceptPaperOnlyArtifacts() { + assertEquals(Collections.singletonList("bukkit"), InstalledPluginLoader.forProfile("BUKKIT")); + assertFalse(InstalledPluginLoader.forProfile("SPIGOT").contains("paper")); + assertTrue(InstalledPluginLoader.forProfile("PAPER").contains("bukkit")); + assertFalse(InstalledPluginLoader.forProfile("PAPER").contains("purpur")); + } + /** A real TCP HTTP fixture, not a stubbed return value, covers transport status and parsing failures. */ + static final class FixtureServer implements AutoCloseable { + final ServerSocket socket = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")); + final ExecutorService executor = Executors.newSingleThreadExecutor(); volatile String request; + FixtureServer(String body, int status) throws IOException { + executor.submit(() -> { + try (Socket client = socket.accept()) { + BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.US_ASCII)); request = reader.readLine(); + String line; while ((line = reader.readLine()) != null && !line.isEmpty()) {} + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + OutputStream output = client.getOutputStream(); output.write(("HTTP/1.1 " + status + " Result\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); output.write(bytes); output.flush(); + } catch (IOException exception) { if (!socket.isClosed()) throw new UncheckedIOException(exception); } + }); + } + String url() { return "http://127.0.0.1:" + socket.getLocalPort(); } + @Override public void close() throws IOException { socket.close(); executor.shutdownNow(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ServerModProfilesTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ServerModProfilesTest.java new file mode 100644 index 00000000..92a14ec1 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ServerModProfilesTest.java @@ -0,0 +1,28 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class ServerModProfilesTest { + @TempDir Path temporary; + @Test void serverModsPersistCloneAndUseIndependentActiveDefaultsWithBothCommandAliases() throws Exception { + ProfileStore store = new ProfileStore(temporary.resolve("autoplug")); + ProfileUpdater updater = new ProfileUpdater(store.getCache(), (profile, installed) -> { throw new AssertionError("Empty profiles have no lookup"); }, (url, destination) -> { throw new AssertionError("Empty profiles have no download"); }); + ProfileCommands commands = new ProfileCommands(store, updater); List output = new ArrayList<>(); + assertTrue(commands.handle(".profiles create server 1.20.4 fabric --type mods-server --template", output::add)); + Profile server = store.get("server"); assertEquals(Profile.Type.MODS_SERVER, server.type); assertEquals(server.directoryPath.resolve("mods"), server.assetsDirectory()); + assertEquals(Profile.Type.MODS_SERVER, new ProfileStore(store.getRoot()).get("server").type); + store.create("client", Profile.Type.MODS, "1.20.4", "FABRIC", false); store.setActive("client"); + commands.handle(".profiles activate server", output::add); assertEquals("server", store.getActive(Profile.Type.MODS_SERVER).id); assertEquals("client", store.getActive(Profile.Type.MODS).id); + output.clear(); assertTrue(commands.handle(".check mods-server", output::add)); assertFalse(output.stream().anyMatch(text -> text.contains("failed"))); + output.clear(); assertTrue(commands.handle(".update mods_server", output::add)); assertFalse(output.stream().anyMatch(text -> text.contains("failed"))); + output.clear(); assertTrue(commands.handle(".check mods --profile server", output::add)); assertFalse(output.stream().anyMatch(text -> text.contains("failed"))); + commands.handle(".profiles clone server 1.20.1 downgraded", output::add); assertEquals(Profile.Type.MODS_SERVER, store.get("downgraded").type); assertEquals("1.20.1", store.get("downgraded").gameVersion); + output.clear(); commands.handle(".profiles list --type mods_server", output::add); assertEquals(2, output.size()); assertTrue(output.stream().allMatch(text -> text.contains("MODS_SERVER"))); + store.delete("server"); assertNull(store.getActive(Profile.Type.MODS_SERVER)); assertEquals("client", store.getActive(Profile.Type.MODS).id); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/UnifiedProfilesTest.java b/src/test/java/com/osiris/autoplug/client/profiles/UnifiedProfilesTest.java new file mode 100644 index 00000000..2ed9668d --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/UnifiedProfilesTest.java @@ -0,0 +1,224 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class UnifiedProfilesTest { + @TempDir Path directory; + private Path jar(Path file, String contents) throws IOException { + Files.createDirectories(file.getParent()); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(file))) { + zip.putNextEntry(new ZipEntry("fixture.txt")); zip.write(contents.getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); + } + return file; + } + private ProfileUpdater.Resolution release(String project, String version, String filename, Path bytes) throws IOException { + return new ProfileUpdater.Resolution(true, project, version, version, filename, bytes.toUri().toString(), SharedArtifactCache.hash(bytes, "SHA-1"), SharedArtifactCache.hash(bytes, "SHA-512"), Collections.emptyList()); + } + private ProfileUpdater updater(ProfileStore store, ProfileUpdater.Resolver resolver) { + return new ProfileUpdater(store.getCache(), resolver, (url, destination) -> Files.copy(Paths.get(java.net.URI.create(url)), destination)); + } + @Test void persistsBothTypesTemplatesAndIsolatedDirectories() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); + Profile mods = store.create("survival", Profile.Type.MODS, "1.20.1", "fabric", true); + Profile plugins = store.create("hub", Profile.Type.PLUGINS, "1.21.1", "paper", false); + assertEquals("FABRIC", mods.loader); assertTrue(mods.isTemplate); + assertEquals(mods.directoryPath.resolve("mods"), mods.assetsDirectory()); + assertEquals(plugins.directoryPath.resolve("plugins"), plugins.assetsDirectory()); + assertEquals(2, new ProfileStore(store.getRoot()).list().size()); + assertFalse(store.setTemplate("survival", false).isTemplate); + assertThrows(IllegalArgumentException.class, () -> store.create("../escape", Profile.Type.MODS, "1.20", "fabric", false)); + assertThrows(IOException.class, () -> store.create("hub", Profile.Type.MODS, "1.20", "fabric", false)); + } + @Test void concurrentStoresPreserveIndependentDefaultsDuringActivationAndDeletion() throws Exception { + ProfileStore first = new ProfileStore(directory.resolve("autoplug")); + ProfileStore second = new ProfileStore(first.getRoot().resolve("unused").resolve("..")); + first.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + first.create("plugins", Profile.Type.PLUGINS, "1.20.1", "paper", false); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + for (int round = 0; round < 32; round++) { + Files.deleteIfExists(first.getRoot().resolve("active-profiles.properties")); + CountDownLatch start = new CountDownLatch(1); + Future mods = workers.submit(() -> { start.await(); return first.setActive("mods"); }); + Future plugins = workers.submit(() -> { start.await(); return second.setActive("plugins"); }); + start.countDown(); mods.get(10, TimeUnit.SECONDS); plugins.get(10, TimeUnit.SECONDS); + assertEquals("mods", second.getActive(Profile.Type.MODS).id, "Lost MODS default in round " + round); + assertEquals("plugins", first.getActive(Profile.Type.PLUGINS).id, "Lost PLUGINS default in round " + round); + } + CountDownLatch start = new CountDownLatch(1); + Future deletion = workers.submit(() -> { start.await(); first.delete("mods"); return null; }); + Future activation = workers.submit(() -> { start.await(); return second.setActive("plugins"); }); + start.countDown(); deletion.get(10, TimeUnit.SECONDS); activation.get(10, TimeUnit.SECONDS); + assertNull(second.getActive(Profile.Type.MODS)); + assertEquals("plugins", first.getActive(Profile.Type.PLUGINS).id); + } finally { workers.shutdownNow(); } + } + @Test void corruptOrIncompleteMetadataFailsWithActionableIOException() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); store.create("profile", Profile.Type.MODS, "1.20.1", "fabric", false); + Path metadata = store.getRoot().resolve("profiles/profile.properties"); + Files.write(metadata, Collections.singletonList("id=profile")); assertThrows(IOException.class, () -> store.get("profile")); + Files.write(metadata, Arrays.asList("id=profile", "type=MODS", "gameVersion=1.20.1", "loader=fabric", "isTemplate=perhaps")); assertThrows(IOException.class, () -> store.get("profile")); + } + @Test void clonesContentThroughSharedCacheButCopiesMutableConfiguration() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); + Profile source = store.create("base", Profile.Type.MODS, "1.21.1", "fabric", true); + jar(source.assetsDirectory().resolve("mod.jar"), "newer"); + Files.createDirectories(source.directoryPath.resolve("config")); + Files.write(source.directoryPath.resolve("config/settings.txt"), Collections.singletonList("source")); + Files.write(source.directoryPath.resolve("credentials.json"), Collections.singletonList("must not clone")); + Profile clone = store.clone("base", "older", "1.20.1"); + assertEquals("1.20.1", clone.gameVersion); assertFalse(clone.isTemplate); + assertEquals(SharedArtifactCache.hash(source.assetsDirectory().resolve("mod.jar"), "SHA-256"), SharedArtifactCache.hash(clone.assetsDirectory().resolve("mod.jar"), "SHA-256")); + try (java.util.stream.Stream files = Files.list(store.getCache().getRoot())) { assertEquals(1, files.count()); } + Files.write(clone.directoryPath.resolve("config/settings.txt"), Collections.singletonList("clone")); + assertEquals("source", Files.readAllLines(source.directoryPath.resolve("config/settings.txt")).get(0)); + assertFalse(Files.exists(clone.directoryPath.resolve("credentials.json"))); + store.delete("older"); assertTrue(Files.exists(source.assetsDirectory().resolve("mod.jar"))); + } + @Test void migratesToOlderReleaseDespiteNewerInstalledFileTimestamp() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); + Profile profile = store.create("older", Profile.Type.MODS, "1.20.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("new.jar"), "new"); + Path replacement = jar(directory.resolve("older-release.jar"), "old"); + Files.setLastModifiedTime(original, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis())); + ProfileUpdater updater = updater(store, (p, path) -> release("project", "1.0", "old.jar", replacement)); + ProfileUpdater.Plan plan = updater.check(profile); + assertEquals(ProfileUpdater.Action.UPDATE, plan.changes.get(0).action); + updater.apply(plan, false); + assertFalse(Files.exists(original)); assertEquals(SharedArtifactCache.hash(replacement, "SHA-256"), SharedArtifactCache.hash(profile.assetsDirectory().resolve("old.jar"), "SHA-256")); + } + @Test void explicitDisablingConfirmationAndLaterReEnable() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); + Profile profile = store.create("mods", Profile.Type.MODS, "1.21.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("unavailable.jar"), "local"); + ProfileUpdater unavailable = updater(store, (p, path) -> ProfileUpdater.Resolution.unsupported("project")); + ProfileUpdater.Plan plan = unavailable.check(profile); + assertEquals(1, plan.disabled().size()); assertThrows(IOException.class, () -> unavailable.apply(plan, false)); assertTrue(Files.exists(original)); + unavailable.apply(plan, true); assertFalse(Files.exists(original)); assertTrue(Files.exists(original.resolveSibling("unavailable.jar.disabled"))); + Path restored = jar(directory.resolve("restored.jar"), "compatible"); + ProfileUpdater available = updater(store, (p, path) -> release("project", "2", "available.jar", restored)); + ProfileUpdater.Plan restoredPlan = available.check(profile); + assertEquals(ProfileUpdater.Action.ENABLE, restoredPlan.changes.get(0).action); + available.apply(restoredPlan, false); assertFalse(Files.exists(original.resolveSibling("unavailable.jar.disabled"))); assertTrue(Files.exists(profile.assetsDirectory().resolve("available.jar"))); + } + @Test void apiFailureDoesNotBecomeUnsupportedOrMutateFiles() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("plugins", Profile.Type.PLUGINS, "1.20.1", "paper", false); + Path original = jar(profile.assetsDirectory().resolve("plugin.jar"), "original"); String hash = SharedArtifactCache.hash(original, "SHA-256"); + ProfileUpdater updater = updater(store, (p, path) -> { throw new java.net.SocketTimeoutException("HTTP timeout"); }); + ProfileUpdater.Plan plan = updater.check(profile); + assertEquals(1, plan.errors.size()); assertTrue(plan.disabled().isEmpty()); + assertThrows(IOException.class, () -> updater.apply(plan, true)); assertEquals(hash, SharedArtifactCache.hash(original, "SHA-256")); + } + @Test void failedSecondDownloadLeavesEveryOriginalIntact() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "forge", false); + Path first = jar(profile.assetsDirectory().resolve("first.jar"), "first-old"), second = jar(profile.assetsDirectory().resolve("second.jar"), "second-old"); + Path replacement = jar(directory.resolve("release.jar"), "new"); String firstHash = SharedArtifactCache.hash(first, "SHA-256"), secondHash = SharedArtifactCache.hash(second, "SHA-256"); + int[] downloads = {0}; + ProfileUpdater updater = new ProfileUpdater(store.getCache(), (p, path) -> release(path.getFileName().toString(), "2", path.getFileName().toString(), replacement), (url, destination) -> { if (++downloads[0] == 2) throw new IOException("Download failed"); Files.copy(replacement, destination); }); + ProfileUpdater.Plan plan = updater.check(profile); assertThrows(IOException.class, () -> updater.apply(plan, false)); + assertEquals(firstHash, SharedArtifactCache.hash(first, "SHA-256")); assertEquals(secondHash, SharedArtifactCache.hash(second, "SHA-256")); + } + @Test void rejectsChangedFilesAndUnreviewedDestinationCollision() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("original.jar"), "original"), replacement = jar(directory.resolve("release.jar"), "new"); + ProfileUpdater updater = updater(store, (p, path) -> release("project", "2", "target.jar", replacement)); + ProfileUpdater.Plan plan = updater.check(profile); jar(original, "changed-after-check"); assertThrows(IOException.class, () -> updater.apply(plan, false)); + ProfileUpdater.Plan updated = updater.check(profile); jar(profile.assetsDirectory().resolve("target.jar"), "unreviewed-new-file"); assertThrows(IOException.class, () -> updater.apply(updated, false)); + } + @Test void checksumMismatchRejectsUntrustedDownloadBeforeReplacement() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("mod.jar"), "original"), expected = jar(directory.resolve("expected.jar"), "expected"), wrong = jar(directory.resolve("wrong.jar"), "wrong"); + String hash = SharedArtifactCache.hash(original, "SHA-256"); + ProfileUpdater updater = new ProfileUpdater(store.getCache(), (p, path) -> release("project", "2", "mod.jar", expected), (url, destination) -> Files.copy(wrong, destination)); + ProfileUpdater.Plan plan = updater.check(profile); assertThrows(IOException.class, () -> updater.apply(plan, false)); assertEquals(hash, SharedArtifactCache.hash(original, "SHA-256")); + } + @Test void cliExtendsLegacyCommandsAndMigratesPluginClone() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile source = store.create("hub", Profile.Type.PLUGINS, "1.21.1", "paper", true); + Path original = jar(source.assetsDirectory().resolve("plugin.jar"), "old"); Path replacement = jar(directory.resolve("new.jar"), "new"); + ProfileUpdater updater = updater(store, (p, path) -> release("plugin", "1", "plugin.jar", replacement)); ProfileCommands commands = new ProfileCommands(store, updater); + String originalHash = SharedArtifactCache.hash(original, "SHA-256"); + List output = new ArrayList<>(); assertFalse(commands.handle(".check plugins", output::add)); + assertTrue(commands.handle(".check plugins --profile hub", output::add)); assertEquals(originalHash, SharedArtifactCache.hash(original, "SHA-256")); + assertTrue(commands.handle(".profiles clone hub 1.20.1 older", output::add)); + assertEquals(SharedArtifactCache.hash(replacement, "SHA-256"), SharedArtifactCache.hash(store.get("older").assetsDirectory().resolve("plugin.jar"), "SHA-256")); + assertTrue(output.stream().anyMatch(text -> text.contains("Cloned and migrated"))); + assertEquals("1.21.1", store.get("hub").gameVersion); + } + @Test void cliCloneWithoutDisableConfirmationIsCleanedUp() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile source = store.create("base", Profile.Type.MODS, "1.20.1", "fabric", true); jar(source.assetsDirectory().resolve("mod.jar"), "old"); + ProfileCommands commands = new ProfileCommands(store, updater(store, (p, path) -> ProfileUpdater.Resolution.unsupported("project"))); List output = new ArrayList<>(); + commands.handle(".profiles clone base 1.21.1 clone", output::add); + assertEquals(1, store.list().size()); assertTrue(Files.exists(source.assetsDirectory().resolve("mod.jar"))); assertTrue(output.stream().anyMatch(text -> text.contains("Confirmation required"))); + commands.handle(".profiles clone base 1.21.1 clone --confirm-disabling", output::add); + assertEquals(2, store.list().size()); assertTrue(Files.exists(store.get("clone").assetsDirectory().resolve("mod.jar.disabled"))); + } + @Test void recoversPreparedInterruptedTransactionBeforeApplying() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); Path original = jar(profile.assetsDirectory().resolve("original.jar"), "original"); + ProfileUpdater updater = updater(store, (p, path) -> ProfileUpdater.Resolution.unsupported("project")); ProfileUpdater.Plan plan = updater.check(profile); + Path transaction = profile.directoryPath.resolve(".autoplug-transactions/update-interrupted"); Files.createDirectories(transaction); Files.copy(original, transaction.resolve("0.backup")); + Path destination = original.resolveSibling("original.jar.disabled"); Files.move(original, destination); + Properties journal = new Properties(); journal.setProperty("state", "PREPARED"); journal.setProperty("count", "1"); journal.setProperty("0.original", "mods/original.jar"); journal.setProperty("0.destination", "mods/original.jar.disabled"); + try (OutputStream output = Files.newOutputStream(transaction.resolve("journal.properties"))) { journal.store(output, "fixture"); } + updater.apply(plan, true); assertTrue(Files.exists(destination)); assertFalse(Files.exists(transaction)); + } + @Test void installsRequiredDependencyAndPreservesProviderIdentityAfterRename() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + jar(profile.assetsDirectory().resolve("old.jar"), "old"); Path main = jar(directory.resolve("main.jar"), "main"), dependency = jar(directory.resolve("dependency.jar"), "dependency"); + ProfileUpdater.Resolver resolver = new ProfileUpdater.Resolver() { + @Override public ProfileUpdater.Resolution resolve(Profile p, Path installed) throws Exception { + ProfileUpdater.Resolution base = release("main", "2", "new.jar", main); + return new ProfileUpdater.Resolution(true, base.projectId, base.versionId, base.version, base.fileName, base.url, base.sha1, base.sha512, Collections.singletonList(new ProfileUpdater.Dependency("dependency", "pinned"))); + } + @Override public ProfileUpdater.Resolution dependency(Profile p, ProfileUpdater.Dependency requested) throws Exception { assertEquals("pinned", requested.versionId); return release("dependency", "pinned", "dependency.jar", dependency); } + }; + ProfileUpdater updater = updater(store, resolver); ProfileUpdater.Plan plan = updater.check(profile); + assertTrue(plan.errors.isEmpty()); assertEquals(2, plan.changes.size()); assertEquals(ProfileUpdater.Action.ADD, plan.changes.get(1).action); + updater.apply(plan, false); assertTrue(Files.exists(profile.assetsDirectory().resolve("dependency.jar"))); assertFalse(Files.exists(profile.assetsDirectory().resolve("old.jar"))); + Properties identities = new Properties(); try (InputStream input = Files.newInputStream(profile.directoryPath.resolve("asset-sources.properties"))) { identities.load(input); } + assertEquals("main", identities.getProperty("new.jar.modrinth")); assertEquals("dependency", identities.getProperty("dependency.jar.modrinth")); + } + @Test void rejectsMutuallyIncompatibleProjectsBeforeAnyMutation() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path one = jar(profile.assetsDirectory().resolve("one.jar"), "one"), two = jar(profile.assetsDirectory().resolve("two.jar"), "two"); + ProfileUpdater updater = updater(store, (p, installed) -> { + ProfileUpdater.Resolution base = release(installed.equals(one) ? "one" : "two", "same", installed.getFileName().toString(), installed); + return new ProfileUpdater.Resolution(true, base.projectId, base.versionId, base.version, base.fileName, base.url, base.sha1, base.sha512, null, Collections.emptyList(), installed.equals(one) ? Collections.singletonList("two") : Collections.emptyList()); + }); + ProfileUpdater.Plan plan = updater.check(profile); assertEquals(1, plan.errors.size()); assertThrows(IOException.class, () -> updater.apply(plan, true)); assertTrue(Files.exists(one)); assertTrue(Files.exists(two)); + } + @Test void detectsCacheCorruptionAndRejectsMutationDuringStaging() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("mod.jar"), "original"), replacement = jar(directory.resolve("replacement.jar"), "replacement"); + Path cached = store.getCache().store(original); Files.write(cached, Collections.singletonList("corrupt")); assertThrows(IOException.class, () -> store.getCache().link(cached, directory.resolve("link.jar"))); + ProfileUpdater updater = new ProfileUpdater(store.getCache(), (p, installed) -> release("project", "2", "mod.jar", replacement), (url, output) -> { Files.copy(replacement, output); jar(original, "owner edited while download active"); }); + ProfileUpdater.Plan plan = updater.check(profile); assertThrows(IOException.class, () -> updater.apply(plan, false)); + assertNotEquals(SharedArtifactCache.hash(replacement, "SHA-256"), SharedArtifactCache.hash(original, "SHA-256")); + } + @Test void recoveryRestoresOtherOriginalsEvenWhenOneDestinationCannotBeRemoved() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path one = jar(profile.assetsDirectory().resolve("one.jar"), "one"), two = jar(profile.assetsDirectory().resolve("two.jar"), "two"); + String hash = SharedArtifactCache.hash(one, "SHA-256"); ProfileUpdater updater = updater(store, (p, path) -> ProfileUpdater.Resolution.unsupported(path.getFileName().toString())); ProfileUpdater.Plan plan = updater.check(profile); + Path transaction = profile.directoryPath.resolve(".autoplug-transactions/update-interrupted"); Files.createDirectories(transaction); Files.copy(one, transaction.resolve("0.backup")); Files.copy(two, transaction.resolve("1.backup")); Files.move(one, one.resolveSibling("one.jar.disabled")); + Path blocked = two.resolveSibling("two.jar.disabled"); Files.createDirectory(blocked); Files.write(blocked.resolve("block-removal"), Collections.singletonList("fixture")); + Properties journal = new Properties(); journal.setProperty("state", "PREPARED"); journal.setProperty("count", "2"); + journal.setProperty("0.original", "mods/one.jar"); journal.setProperty("0.destination", "mods/one.jar.disabled"); journal.setProperty("1.original", "mods/two.jar"); journal.setProperty("1.destination", "mods/two.jar.disabled"); + try (OutputStream output = Files.newOutputStream(transaction.resolve("journal.properties"))) { journal.store(output, "fixture"); } + assertThrows(IOException.class, () -> updater.apply(plan, true)); assertEquals(hash, SharedArtifactCache.hash(one, "SHA-256")); assertTrue(Files.exists(two)); assertTrue(Files.exists(transaction.resolve("0.backup"))); + } + @Test void newlyAppearedDestinationDuringDownloadIsNotOverwritten() throws Exception { + ProfileStore store = new ProfileStore(directory.resolve("autoplug")); Profile profile = store.create("mods", Profile.Type.MODS, "1.20.1", "fabric", false); + Path original = jar(profile.assetsDirectory().resolve("old.jar"), "old"), release = jar(directory.resolve("release.jar"), "release"); String originalHash = SharedArtifactCache.hash(original, "SHA-256"); + Path destination = profile.assetsDirectory().resolve("new.jar"); + ProfileUpdater updater = new ProfileUpdater(store.getCache(), (p, path) -> release("project", "2", "new.jar", release), (url, download) -> { Files.copy(release, download); jar(destination, "owner-created"); }); + ProfileUpdater.Plan plan = updater.check(profile); assertThrows(IOException.class, () -> updater.apply(plan, false)); assertEquals(originalHash, SharedArtifactCache.hash(original, "SHA-256")); assertNotEquals(SharedArtifactCache.hash(release, "SHA-256"), SharedArtifactCache.hash(destination, "SHA-256")); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/VirtualWorldServiceTest.java b/src/test/java/com/osiris/autoplug/client/worlds/VirtualWorldServiceTest.java new file mode 100644 index 00000000..fd2f395e --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/VirtualWorldServiceTest.java @@ -0,0 +1,188 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.clientapp.*; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.net.*; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Runs original child JVM fixtures and loopback MineStat readiness; never launches Minecraft or maps a real router. */ +class VirtualWorldServiceTest { + @TempDir Path temporary; + private Path java() { return Paths.get(System.getProperty("java.home"), "bin", LauncherPlatform.current().os.equals("windows") ? "java.exe" : "java"); } + private WorldStore store() throws IOException { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); profiles.create("paper", Profile.Type.PLUGINS, "1.20.4", "PAPER", false); return new WorldStore(profiles.getRoot(), profiles); + } + private WorldDefinition readyWorld(WorldStore store) throws IOException { + WorldDefinition world = store.create("Fixture world", "paper"); store.setServerExecutable(world.id, WorldServerPreparerTest.fixtureJar(temporary, WorldFixtureProcesses.class.getName(), "1.20.4")); return store.acceptEula(world.id, true); + } + private WorldServerPreparer preparer() throws IOException { return WorldServerPreparerTest.offlinePreparer(temporary.resolve("cache"), java(), new ArrayList<>(), false); } + private Process client(WorldStore store, WorldDefinition world, String id, ServerAddress address, long delay) throws IOException { + Profile profile = store.getProfiles().get(id); + return new ProcessBuilder(java().toString(), "-jar", world.serverExecutable.toString(), "client", address.host + ":" + address.port, Long.toString(delay)).directory(profile.directoryPath.toFile()).start(); + } + private void awaitClosed(VirtualWorldService.RunningWorld running) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(12); + while (!running.isClosed() && System.nanoTime() < deadline) Thread.sleep(40); + assertTrue(running.isClosed(), "World lifecycle did not clean up"); assertFalse(running.serverProcess().isAlive()); + if (running.clientProcess() != null) assertFalse(running.clientProcess().isAlive()); + } + @Test void realChildServerAndNativeServiceClientBridgeUseLocalPortThenGracefulStopAndUnmapOnClientExit() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicInteger mapped = new AtomicInteger(), unmapped = new AtomicInteger(); + NativeClientService nativeClient = new NativeClientService(store.getProfiles(), new ServerCatalog(store.getRoot()), new ClientSettings(store.getRoot()), (profile, account, address) -> { + assertEquals(Profile.Type.MODS, profile.type); assertEquals("VANILLA", profile.loader); assertEquals("1.20.4", profile.gameVersion); assertEquals("Player", account.getName()); + return client(store, world, profile.id, address, 1400); + }, new NativeClientService.Migration() { + public ProfileUpdater.Plan check(Profile profile) { throw new AssertionError("No world migration requested"); } + public void apply(ProfileUpdater.Plan plan, boolean allowDisabling) { throw new AssertionError("No world migration requested"); } + }); + VirtualWorldService.Sharing sharing = port -> { + mapped.incrementAndGet(); assertTrue(port > 0); + return new VirtualWorldService.Mapping() { public String joinAddress() { return "198.51.100.10:" + port; } public void close() { unmapped.incrementAndGet(); } }; + }; + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), nativeClient::launch, sharing, Duration.ofSeconds(8), Duration.ofSeconds(3))) { + VirtualWorldService.RunningWorld active = service.launch(world.id, true); + assertEquals("127.0.0.1", active.address.host); assertTrue(active.serverProcess().isAlive()); assertNotNull(active.publicAddress()); assertEquals(1, mapped.get()); + assertThrows(IOException.class, () -> service.launch(world.id, false)); assertThrows(IOException.class, () -> store.delete(world.id)); + assertTrue(active.clientProcess().waitFor(8, TimeUnit.SECONDS)); assertEquals(0, active.clientProcess().exitValue()); awaitClosed(active); + assertEquals(1, unmapped.get()); assertFalse(service.isRunning(world.id)); assertTrue(Files.exists(world.directoryPath.resolve("fixture-stopped.txt"))); + assertTrue(active.recentOutput().size() <= 80); for (String line : active.recentOutput()) assertTrue(line.length() <= 2048); + Profile clientProfile = store.getProfiles().get("world-client-" + world.id); Properties record = new Properties(); + try (InputStream input = Files.newInputStream(clientProfile.directoryPath.resolve("fixture-client.properties"))) { record.load(input); } + assertEquals("127.0.0.1:" + active.address.port, record.getProperty("address")); assertEquals(clientProfile.directoryPath.toString(), record.getProperty("workingDirectory")); + assertFalse(Files.exists(temporary.resolve("server.properties"))); assertFalse(Files.exists(store.getProfiles().get("paper").directoryPath.resolve("server.properties"))); + } + } + @Test void failedUpnpFallsBackToLocalGameWithNoticeAndLoopbackOnlyWorldRequiresRestartToShare() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), (id, address) -> client(store, world, id, address, 2500), port -> { throw new IOException("Fixture router unavailable"); }, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + VirtualWorldService.RunningWorld active = service.launch(world.id, true); + assertNull(active.publicAddress()); assertTrue(active.shareFailure().contains("Fixture router unavailable")); assertTrue(active.shareFailure().contains("CGNAT")); + active.close(); awaitClosed(active); + VirtualWorldService.RunningWorld local = service.launch(world.id, false); + IOException failure = assertThrows(IOException.class, () -> service.share(world.id)); assertTrue(failure.getMessage().contains("Restart")); + local.close(); awaitClosed(local); + } + } + @Test void clientPreparationFailureStopsRealServerAndRemovesMapping() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicReference owner = new AtomicReference<>(); AtomicReference observed = new AtomicReference<>(); AtomicInteger unmapped = new AtomicInteger(); + WorldServerPreparer production = preparer(); + try (VirtualWorldService service = new VirtualWorldService(store, (definition, port, share) -> { observed.set(owner.get().running(definition.id)); return production.prepare(definition, port, share); }, (id, address) -> { throw new IOException("Fixture client preparation failed"); }, port -> new VirtualWorldService.Mapping() { public String joinAddress() { return "198.51.100.10:" + port; } public void close() { unmapped.incrementAndGet(); } }, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + owner.set(service); assertThrows(IOException.class, () -> service.launch(world.id, true)); awaitClosed(observed.get()); assertEquals(1, unmapped.get()); assertFalse(service.isRunning(world.id)); assertTrue(Files.exists(world.directoryPath.resolve("fixture-stopped.txt"))); + } + } + @Test void readinessFailureAndEarlyServerExitCleanUpWithoutLaunchingClient() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicReference owner = new AtomicReference<>(); AtomicReference observed = new AtomicReference<>(); WorldServerPreparer production = preparer(); AtomicInteger clientCalls = new AtomicInteger(); + try (VirtualWorldService service = new VirtualWorldService(store, (definition, port, share) -> { observed.set(owner.get().running(definition.id)); return production.prepare(definition, port, share); }, (id, address) -> { clientCalls.incrementAndGet(); throw new AssertionError("Unready server must not launch a client"); }, null, Duration.ofMillis(1300), Duration.ofSeconds(2))) { + owner.set(service); + Files.write(world.directoryPath.resolve("fixture-mode.txt"), Collections.singletonList("silent")); assertThrows(IOException.class, () -> service.launch(world.id, false)); awaitClosed(observed.get()); assertEquals(0, clientCalls.get()); + Files.write(world.directoryPath.resolve("fixture-mode.txt"), Collections.singletonList("exit")); assertThrows(IOException.class, () -> service.launch(world.id, false)); awaitClosed(observed.get()); assertEquals(0, clientCalls.get()); + } + } + @Test void startupCancellationKillsCreatedServerAndNeverStartsClientAndStopAllKillsStubbornChildren() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicReference owner = new AtomicReference<>(); AtomicReference observed = new AtomicReference<>(); WorldServerPreparer production = preparer(); AtomicInteger clientCalls = new AtomicInteger(); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try (VirtualWorldService service = new VirtualWorldService(store, (definition, port, share) -> { observed.set(owner.get().running(definition.id)); return production.prepare(definition, port, share); }, (id, address) -> { clientCalls.incrementAndGet(); return client(store, world, id, address, 20000); }, null, Duration.ofSeconds(8), Duration.ofMillis(150))) { + owner.set(service); Files.write(world.directoryPath.resolve("fixture-mode.txt"), Collections.singletonList("silent")); + Future launching = worker.submit(() -> { try { service.launch(world.id, false); throw new AssertionError("Cancelled startup unexpectedly completed"); } catch (InterruptedException cancelled) { } catch (Exception failed) { throw new CompletionException(failed); } }); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(6); + while ((observed.get() == null || observed.get().serverProcess() == null) && System.nanoTime() < deadline) Thread.sleep(30); + assertNotNull(observed.get()); assertNotNull(observed.get().serverProcess()); service.stop(world.id); launching.get(8, TimeUnit.SECONDS); awaitClosed(observed.get()); assertEquals(0, clientCalls.get()); + Files.write(world.directoryPath.resolve("fixture-mode.txt"), Collections.singletonList("stubborn")); VirtualWorldService.RunningWorld active = service.launch(world.id, false); + service.stopAll(); awaitClosed(active); assertFalse(service.isRunning(world.id)); + } finally { worker.shutdownNow(); } + } + @Test void explicitEulaAndServerModClientConfigurationAreRequiredBeforeAnyPreparation() throws Exception { + WorldStore store = store(); WorldDefinition unaccepted = store.create("No EULA", "paper"); AtomicInteger preparations = new AtomicInteger(); + try (VirtualWorldService service = new VirtualWorldService(store, (world, port, share) -> { preparations.incrementAndGet(); throw new AssertionError("Unaccepted world must not be prepared"); }, (id, address) -> { throw new AssertionError("Unaccepted world must not launch a client"); }, null, Duration.ofSeconds(2), Duration.ofSeconds(1))) { + assertThrows(IOException.class, () -> service.launch(unaccepted.id, false)); assertEquals(0, preparations.get()); assertFalse(Files.exists(unaccepted.directoryPath.resolve("eula.txt"))); + store.getProfiles().create("servermods", Profile.Type.MODS_SERVER, "1.20.4", "FABRIC", false); WorldDefinition mods = store.create("mods", "servermods"); store.acceptEula(mods.id, true); + assertThrows(IOException.class, () -> service.launch(mods.id, false)); assertEquals(0, preparations.get()); + } + } + @Test void longWorldIdsGenerateSeparateClientProfilesInsteadOfSharingTruncatedConfigDirectories() throws Exception { + WorldStore store = store(); String prefix = String.join("", Collections.nCopies(65, "x")); + WorldDefinition first = store.create(prefix + "a", "first", "paper"), second = store.create(prefix + "b", "second", "paper"); + store.acceptEula(first.id, true); store.acceptEula(second.id, true); + try (VirtualWorldService service = new VirtualWorldService(store, (world, port, share) -> { throw new IOException("Fixture stops after client profile allocation"); }, (id, address) -> { throw new AssertionError("No client spawn expected"); }, null, Duration.ofSeconds(2), Duration.ofSeconds(1))) { + assertThrows(IOException.class, () -> service.launch(first.id, false)); assertThrows(IOException.class, () -> service.launch(second.id, false)); + List clients = new ArrayList<>(); for (Profile profile : store.getProfiles().list()) if (profile.type == Profile.Type.MODS) clients.add(profile); + assertEquals(2, clients.size()); assertNotEquals(clients.get(0).id, clients.get(1).id); assertNotEquals(clients.get(0).directoryPath, clients.get(1).directoryPath); + Files.write(clients.get(0).directoryPath.resolve("options.txt"), Collections.singletonList("first world only")); assertFalse(Files.exists(clients.get(1).directoryPath.resolve("options.txt"))); + } + } + @Test void configuredPortIsUsedAndAnOccupiedConfiguredPortFailsWithoutSilentlyChoosingAnother() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); int preferred; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { preferred = socket.getLocalPort(); } + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), (id, address) -> client(store, world, id, address, 20000), null, () -> preferred, () -> true, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + VirtualWorldService.RunningWorld active = service.launch(world.id, false); assertEquals(preferred, active.address.port); + WorldDefinition second = store.create("second", "paper"); store.acceptEula(second.id, true); + IOException occupied = assertThrows(IOException.class, () -> service.launch(second.id, false)); assertTrue(occupied.getMessage().contains(Integer.toString(preferred))); assertTrue(occupied.getMessage().contains("unavailable")); + assertFalse(service.isRunning(second.id)); assertTrue(active.serverProcess().isAlive()); active.close(); awaitClosed(active); + } + } + @Test void disabledUpnpPreferencePreventsSharingBeforeAnyPreparationOrRouterCall() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicInteger preparations = new AtomicInteger(), routerCalls = new AtomicInteger(); + try (VirtualWorldService service = new VirtualWorldService(store, (definition, port, share) -> { preparations.incrementAndGet(); throw new AssertionError("Sharing is disabled"); }, (id, address) -> { throw new AssertionError("No client expected"); }, port -> { routerCalls.incrementAndGet(); throw new AssertionError("No router request expected"); }, () -> 0, () -> false, Duration.ofSeconds(2), Duration.ofSeconds(1))) { + IOException disabled = assertThrows(IOException.class, () -> service.launch(world.id, true)); assertTrue(disabled.getMessage().contains("disabled")); assertEquals(0, preparations.get()); assertEquals(0, routerCalls.get()); assertFalse(service.isRunning(world.id)); + } + } + @Test void closeWaitsForCancelledStartupCleanupRatherThanJoiningItsReusableWorkerThread() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); CountDownLatch entered = new CountDownLatch(1); AtomicReference active = new AtomicReference<>(); AtomicReference owner = new AtomicReference<>(); ExecutorService worker = Executors.newSingleThreadExecutor(); + try (VirtualWorldService service = new VirtualWorldService(store, (definition, port, share) -> { active.set(owner.get().running(definition.id)); entered.countDown(); new CountDownLatch(1).await(); throw new AssertionError("Cancelled startup advanced"); }, (id, address) -> { throw new AssertionError("Cancelled startup must not spawn a client"); }, null, Duration.ofSeconds(2), Duration.ofSeconds(1))) { + owner.set(service); + Future result = worker.submit(() -> { try { service.launch(world.id, false); return false; } catch (InterruptedException cancelled) { return true; } }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); assertThrows(IOException.class, () -> store.delete(world.id)); + long start = System.nanoTime(); service.close(); + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 3000, "Close must wait for the startup operation, not the reusable executor thread"); + assertTrue(result.get(3, TimeUnit.SECONDS)); assertTrue(active.get().isClosed()); assertNull(active.get().serverProcess()); assertFalse(service.isRunning(world.id)); + store.delete(world.id); assertFalse(Files.exists(world.directoryPath)); + } finally { worker.shutdownNow(); } + } + @Test void oneShotWorldCommandWaitsForActualFixtureClientExitThenStopsServerAndClosesMapping() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicReference owner = new AtomicReference<>(); AtomicReference active = new AtomicReference<>(); AtomicInteger unmapped = new AtomicInteger(); + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), (id, address) -> { active.set(owner.get().running(world.id)); return client(store, world, id, address, 650); }, port -> new VirtualWorldService.Mapping() { public String joinAddress() { return "198.51.100.10:" + port; } public void close() { unmapped.incrementAndGet(); } }, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + owner.set(service); List output = new ArrayList<>(); + assertTrue(new WorldCommands(store, service).handle(".mc worlds launch " + world.id + " --share", output::add, true)); + assertNotNull(active.get()); awaitClosed(active.get()); assertEquals(0, active.get().clientProcess().exitValue()); assertEquals(1, unmapped.get()); + assertTrue(Files.exists(world.directoryPath.resolve("fixture-stopped.txt"))); assertFalse(service.isRunning(world.id)); + assertTrue(output.stream().anyMatch(line -> line.startsWith("World client started: 127.0.0.1:"))); assertTrue(output.stream().anyMatch(line -> line.startsWith("Join address: 198.51.100.10:"))); assertFalse(output.stream().anyMatch(line -> line.startsWith("World command failed"))); + } + } + @Test void oneShotCommandOutputFailureImmediatelyAfterLaunchCleansBothRealChildrenAndMapping() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); AtomicReference owner = new AtomicReference<>(); AtomicReference active = new AtomicReference<>(); AtomicInteger unmapped = new AtomicInteger(); AtomicBoolean rejectOutput = new AtomicBoolean(true); + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), (id, address) -> { active.set(owner.get().running(world.id)); return client(store, world, id, address, 20000); }, port -> new VirtualWorldService.Mapping() { public String joinAddress() { return "198.51.100.10:" + port; } public void close() { unmapped.incrementAndGet(); } }, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + owner.set(service); List failureOutput = new ArrayList<>(); + assertTrue(new WorldCommands(store, service).handle(".mc worlds launch " + world.id + " --share", line -> { if (rejectOutput.getAndSet(false)) throw new IllegalStateException("Fixture output consumer failed"); failureOutput.add(line); }, true)); + assertNotNull(active.get()); awaitClosed(active.get()); assertEquals(1, unmapped.get()); assertFalse(service.isRunning(world.id)); assertTrue(Files.exists(world.directoryPath.resolve("fixture-stopped.txt"))); + assertTrue(failureOutput.stream().anyMatch(line -> line.contains("Fixture output consumer failed"))); + } + } + @Test void stoppingBlockedClientPreparationImmediatelyUnmapsWhileRetainingServerOwnershipUntilFinalCleanup() throws Exception { + WorldStore store = store(); WorldDefinition world = readyWorld(store); CountDownLatch clientPreparing = new CountDownLatch(1), releaseClient = new CountDownLatch(1); AtomicReference owner = new AtomicReference<>(); AtomicReference active = new AtomicReference<>(); AtomicInteger unmapped = new AtomicInteger(); ExecutorService worker = Executors.newSingleThreadExecutor(); + try (VirtualWorldService service = new VirtualWorldService(store, preparer(), (id, address) -> { + active.set(owner.get().running(world.id)); clientPreparing.countDown(); boolean interrupted = false; + while (releaseClient.getCount() > 0) try { releaseClient.await(); } catch (InterruptedException cancelled) { interrupted = true; } + if (interrupted) { Thread.currentThread().interrupt(); throw new InterruptedException("Fixture client preparation released after cancellation"); } + throw new AssertionError("Blocked client preparation was not cancelled"); + }, port -> new VirtualWorldService.Mapping() { public String joinAddress() { return "198.51.100.10:" + port; } public void close() { unmapped.incrementAndGet(); } }, Duration.ofSeconds(8), Duration.ofSeconds(2))) { + owner.set(service); + Future result = worker.submit(() -> { try { service.launch(world.id, true); return false; } catch (InterruptedException cancelled) { return true; } }); + assertTrue(clientPreparing.await(8, TimeUnit.SECONDS)); assertNotNull(active.get().publicAddress()); service.stop(world.id); + assertEquals(1, unmapped.get(), "Owned mapping must close even while client preparation ignores interruption"); assertNull(active.get().publicAddress()); assertFalse(active.get().serverProcess().isAlive()); + assertFalse(result.isDone()); assertFalse(active.get().isClosed()); assertThrows(IOException.class, () -> store.delete(world.id)); + releaseClient.countDown(); assertTrue(result.get(5, TimeUnit.SECONDS)); awaitClosed(active.get()); assertEquals(1, unmapped.get(), "Final cleanup must not unmap twice"); + assertFalse(service.isRunning(world.id)); store.delete(world.id); assertFalse(Files.exists(world.directoryPath)); + } finally { releaseClient.countDown(); worker.shutdownNow(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcesses.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcesses.java new file mode 100644 index 00000000..639aef80 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcesses.java @@ -0,0 +1,66 @@ +/* Original MIT fixture code. This is not a Minecraft server or client. */ +package com.osiris.autoplug.client.worlds; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Small real child JVMs speaking only Minecraft status, used to exercise process ownership. */ +public final class WorldFixtureProcesses { + public static void main(String[] arguments) throws Exception { + if (arguments.length > 0 && arguments[0].equals("client")) { client(arguments); return; } + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("server.properties"))) { properties.load(input); } + if (!Files.readAllLines(Paths.get("eula.txt")).contains("eula=true")) throw new IOException("Fixture EULA flag missing"); + String mode = Files.exists(Paths.get("fixture-mode.txt")) ? Files.readAllLines(Paths.get("fixture-mode.txt")).get(0) : "normal"; + if (mode.equals("exit")) { System.out.println("Fixture intentionally failed before readiness"); System.exit(7); } + int port = Integer.parseInt(properties.getProperty("server-port")); + ServerSocket listener = new ServerSocket(); listener.bind(new InetSocketAddress("127.0.0.1", port)); + AtomicBoolean alive = new AtomicBoolean(true); + Thread commandReader = new Thread(() -> { + try (BufferedReader input = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + String command; + while ((command = input.readLine()) != null) if (command.equals("stop") && !mode.equals("stubborn")) { + Files.write(Paths.get("fixture-stopped.txt"), Collections.singletonList("graceful stop")); alive.set(false); listener.close(); return; + } + } catch (IOException ignored) { } + }, "fixture-stop-reader"); commandReader.setDaemon(true); commandReader.start(); + char[] longLine = new char[5000]; Arrays.fill(longLine, 'x'); + for (int i = 0; i < 100; i++) System.out.println(i + ":" + new String(longLine)); + Files.write(Paths.get("fixture-ready.txt"), Collections.singletonList(Integer.toString(port))); + try { + while (alive.get()) try (Socket connection = listener.accept()) { + if (mode.equals("silent")) { Thread.sleep(1800); continue; } + connection.setSoTimeout(2000); + DataInputStream input = new DataInputStream(connection.getInputStream()); + int handshake = readVarInt(input); byte[] ignored = new byte[handshake]; input.readFully(ignored); + int request = readVarInt(input); ignored = new byte[request]; input.readFully(ignored); + byte[] json = "{\"version\":{\"name\":\"Paper 1.20.4\",\"protocol\":765},\"players\":{\"max\":8,\"online\":0},\"description\":{\"text\":\"Original AutoPlug world fixture\"}}".getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream packet = new DataOutputStream(bytes); + writeVarInt(packet, 0); writeVarInt(packet, json.length); packet.write(json); + DataOutputStream output = new DataOutputStream(connection.getOutputStream()); writeVarInt(output, bytes.size()); output.write(bytes.toByteArray()); output.flush(); + } + } catch (SocketException stopped) { if (alive.get()) throw stopped; } + finally { listener.close(); } + } + private static void client(String[] arguments) throws Exception { + Properties record = new Properties(); record.setProperty("address", arguments[1]); record.setProperty("workingDirectory", System.getProperty("user.dir")); + try (OutputStream output = Files.newOutputStream(Paths.get("fixture-client.properties"))) { record.store(output, "Original offline fixture client"); } + // Validate a real TCP connection to the service's chosen localhost endpoint before exiting. + String[] endpoint = arguments[1].split(":"); + try (Socket connected = new Socket(endpoint[0], Integer.parseInt(endpoint[1]))) { + if (!connected.isConnected()) throw new IOException("Client fixture did not connect"); + connected.setSoTimeout(2000); connected.getOutputStream().write(new byte[]{1, 0, 1, 0}); connected.getOutputStream().flush(); + DataInputStream input = new DataInputStream(connected.getInputStream()); int responseLength = readVarInt(input); byte[] response = new byte[responseLength]; input.readFully(response); + } + char[] noise = new char[200000]; Arrays.fill(noise, 'y'); System.out.println(new String(noise)); + Thread.sleep(Long.parseLong(arguments[2])); + } + private static int readVarInt(DataInputStream input) throws IOException { + int result = 0; for (int shift = 0; shift < 35; shift += 7) { int next = input.readUnsignedByte(); result |= (next & 127) << shift; if ((next & 128) == 0) return result; } throw new IOException("Invalid fixture VarInt"); + } + private static void writeVarInt(DataOutputStream output, int value) throws IOException { do { int next = value & 127; value >>>= 7; output.writeByte(value == 0 ? next : next | 128); } while (value != 0); } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldServerPreparerTest.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldServerPreparerTest.java new file mode 100644 index 00000000..2aa49996 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldServerPreparerTest.java @@ -0,0 +1,69 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.google.gson.*; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.jar.*; +import static org.junit.jupiter.api.Assertions.*; + +class WorldServerPreparerTest { + @TempDir Path temporary; + static String resource(String name) throws IOException { + try (InputStream input = WorldServerPreparerTest.class.getResourceAsStream("/" + name)) { assertNotNull(input, name); return new String(input.readAllBytes(), StandardCharsets.UTF_8); } + } + static Path fixtureJar(Path directory, String mainClass, String gameVersion) throws IOException { + Path jar = directory.resolve("fixture-" + UUID.randomUUID() + ".jar"); Manifest manifest = new Manifest(); manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, mainClass); + try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + output.putNextEntry(new JarEntry("version.json")); output.write(("{\"id\":\"" + gameVersion + "\"}").getBytes(StandardCharsets.UTF_8)); output.closeEntry(); + String classFile = WorldFixtureProcesses.class.getName().replace('.', '/') + ".class"; + try (InputStream input = WorldFixtureProcesses.class.getResourceAsStream("/" + classFile)) { assertNotNull(input); output.putNextEntry(new JarEntry(classFile)); output.write(input.readAllBytes()); output.closeEntry(); } + } + return jar; + } + static WorldServerPreparer offlinePreparer(Path cache, Path java, List javaRequests, boolean offline) throws IOException { + byte[] game = resource("native-launcher/1.20.4.json").getBytes(StandardCharsets.UTF_8); + Path gameFile = Files.createTempFile(cache.getParent(), "game-metadata-", ".json"); Files.write(gameFile, game); + JsonObject entry = new JsonObject(); entry.addProperty("id", "1.20.4"); entry.addProperty("url", "https://fixture.test/1.20.4.json"); entry.addProperty("sha1", LauncherDownloads.hash(gameFile, "SHA-1")); + JsonArray versions = new JsonArray(); versions.add(entry); JsonObject manifest = new JsonObject(); manifest.add("versions", versions); + return new WorldServerPreparer(cache, uri -> { + if (uri.toString().equals(NativeLauncher.VERSION_MANIFEST)) return new ByteArrayInputStream(manifest.toString().getBytes(StandardCharsets.UTF_8)); + if (uri.toString().equals("https://fixture.test/1.20.4.json")) return new ByteArrayInputStream(game); + throw new IOException("Unexpected fixture network request: " + uri); + }, major -> { javaRequests.add(major); return java; }, () -> offline); + } + @Test void parsesCapturedOfficialStablePaperPurpurAndFabricMetadataAndPublishedHashes() throws Exception { + WorldServerPreparer.Artifact paper = WorldServerPreparer.paper(JsonParser.parseString(resource("virtual-worlds/paper-1.20.4-builds.json")).getAsJsonArray(), "1.20.4"); + assertEquals("paper/1.20.4/499.jar", paper.cacheKey); assertEquals("SHA-256", paper.algorithm); assertEquals(42781488, paper.size); + assertEquals("cabed3ae77cf55deba7c7d8722bc9cfd5e991201c211665f9265616d9fe5c77b", paper.hash); assertTrue(paper.url.startsWith("https://fill-data.papermc.io/")); + WorldServerPreparer.Artifact purpur = WorldServerPreparer.purpur(JsonParser.parseString(resource("virtual-worlds/purpur-1.20.4-latest.json")).getAsJsonObject(), "1.20.4"); + assertEquals("https://api.purpurmc.org/v2/purpur/1.20.4/2176/download", purpur.url); assertEquals("c753922385b06885306c0b36a689c97f", purpur.hash); assertEquals("MD5", purpur.algorithm); + WorldServerPreparer.Artifact fabric = WorldServerPreparer.fabric(JsonParser.parseString(resource("virtual-worlds/fabric-1.20.4-loaders.json")).getAsJsonArray(), JsonParser.parseString(resource("virtual-worlds/fabric-installers.json")).getAsJsonArray(), "1.20.4"); + assertEquals("https://meta.fabricmc.net/v2/versions/loader/1.20.4/0.19.5/1.1.2/server/jar", fabric.url); assertNull(fabric.hash); + assertThrows(IOException.class, () -> WorldServerPreparer.paper(new JsonArray(), "1.20.4")); + assertThrows(IOException.class, () -> WorldServerPreparer.purpur(JsonParser.parseString(resource("virtual-worlds/purpur-1.20.4-latest.json")).getAsJsonObject(), "1.21.1")); + } + @Test void rejectsWrongVersionNonExecutableAndSpigotBuildToolsJars() throws Exception { + assertThrows(IOException.class, () -> WorldServerPreparer.validateSuppliedJar(fixtureJar(temporary, "example.Main", "1.21.1"), "1.20.4")); + assertThrows(IOException.class, () -> WorldServerPreparer.validateSuppliedJar(fixtureJar(temporary, "org.spigotmc.builder.Builder", "1.20.4"), "1.20.4")); + Path arbitrary = temporary.resolve("arbitrary.jar"); Files.write(arbitrary, new byte[]{1, 2, 3}); assertThrows(IOException.class, () -> WorldServerPreparer.validateSuppliedJar(arbitrary, "1.20.4")); + } + @Test void writesIndependentPropertiesOnlyAfterExplicitEulaAndRequestsMojangJavaVersion() throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); Profile plugins = profiles.create("paper", Profile.Type.PLUGINS, "1.20.4", "PAPER", false); WorldStore worlds = new WorldStore(profiles.getRoot(), profiles); + WorldDefinition world = worlds.create("world", plugins.id); Path jar = fixtureJar(temporary, WorldFixtureProcesses.class.getName(), "1.20.4"); world = worlds.setServerExecutable(world.id, jar); + List javaRequests = new ArrayList<>(); WorldServerPreparer preparer = offlinePreparer(temporary.resolve("cache"), Paths.get(System.getProperty("java.home"), "bin", LauncherPlatform.current().os.equals("windows") ? "java.exe" : "java"), javaRequests, true); + WorldDefinition rejected = world; assertThrows(IOException.class, () -> preparer.prepare(rejected, 25577, false)); assertTrue(javaRequests.isEmpty()); assertFalse(Files.exists(world.directoryPath.resolve("eula.txt"))); + world = worlds.acceptEula(world.id, true); ProcessBuilder command = preparer.prepare(world, 25577, false); + assertEquals(Collections.singletonList(17), javaRequests); assertEquals(world.directoryPath.toFile(), command.directory()); assertEquals("nogui", command.command().get(command.command().size() - 1)); + Properties p = new Properties(); try (InputStream input = Files.newInputStream(world.directoryPath.resolve("server.properties"))) { p.load(input); } + assertEquals("127.0.0.1", p.getProperty("server-ip")); assertEquals("25577", p.getProperty("server-port")); assertEquals("false", p.getProperty("online-mode")); assertEquals("false", p.getProperty("enable-rcon")); + WorldDefinition shared = world; assertThrows(IOException.class, () -> preparer.prepare(shared, 25577, true)); + assertFalse(Files.exists(plugins.directoryPath.resolve("server.properties"))); assertFalse(Files.exists(profiles.getRoot().resolve("server.properties"))); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java new file mode 100644 index 00000000..e1f78234 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java @@ -0,0 +1,67 @@ +/* MIT licensed; see the repository LICENSE. */ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class WorldStoreTest { + @TempDir Path temporary; + @Test void copiesProfileAssetsAndMutableConfigsIntoIndependentWorldsWithoutAccountFilesOrImplicitEula() throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); + Profile source = profiles.create("plugins", Profile.Type.PLUGINS, "1.20.4", "PAPER", false); + Files.write(source.assetsDirectory().resolve("example.jar"), new byte[]{1, 2, 3}); + Files.createDirectories(source.assetsDirectory().resolve("Example")); Files.write(source.assetsDirectory().resolve("Example/settings.yml"), Collections.singletonList("original")); + Files.createDirectories(source.directoryPath.resolve("config")); Files.write(source.directoryPath.resolve("config/setting.toml"), Collections.singletonList("source=true")); + Files.write(source.directoryPath.resolve("credentials.json"), Collections.singletonList("do not copy")); + WorldStore worlds = new WorldStore(profiles.getRoot(), profiles); + WorldDefinition first = worlds.create("Survival world", source.id), second = worlds.create("Survival world", source.id); + assertEquals("survival-world", first.id); assertEquals("survival-world-2", second.id); assertFalse(first.acceptedEula); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(first.directoryPath.resolve("plugins/example.jar"))); + assertFalse(Files.exists(first.directoryPath.resolve("credentials.json"))); assertFalse(Files.exists(first.directoryPath.resolve("eula.txt"))); + Files.write(first.directoryPath.resolve("plugins/Example/settings.yml"), Collections.singletonList("world only")); + assertEquals("original", Files.readAllLines(source.assetsDirectory().resolve("Example/settings.yml")).get(0)); + assertEquals("original", Files.readAllLines(second.directoryPath.resolve("plugins/Example/settings.yml")).get(0)); + assertTrue(worlds.acceptEula(first.id, true).acceptedEula); assertFalse(new WorldStore(profiles.getRoot(), profiles).get(second.id).acceptedEula); + assertEquals(2, worlds.list().size()); worlds.delete(first.id); assertFalse(Files.exists(first.directoryPath)); assertTrue(Files.exists(second.directoryPath)); + } + @Test void serverModsNeedMatchingClientProfilesAndRejectClientOnlyWorldsAndEscapingIds() throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); WorldStore worlds = new WorldStore(profiles.getRoot(), profiles); + Profile server = profiles.create("server-mods", Profile.Type.MODS_SERVER, "1.20.4", "FABRIC", false); + Profile client = profiles.create("client", Profile.Type.MODS, "1.20.4", "FABRIC", false); + profiles.create("wrong-version", Profile.Type.MODS, "1.21.1", "FABRIC", false); profiles.create("wrong-loader", Profile.Type.MODS, "1.20.4", "FORGE", false); + Files.write(server.assetsDirectory().resolve("server-mod.jar.disabled"), new byte[]{9}); WorldDefinition world = worlds.create("Mod world", server.id); + assertTrue(Files.exists(world.directoryPath.resolve("mods/server-mod.jar.disabled"))); assertEquals(Profile.Type.MODS_SERVER, world.profileType); + assertEquals(client.id, worlds.setClientProfile(world.id, client.id).clientProfileId); + assertThrows(IOException.class, () -> worlds.setClientProfile(world.id, "wrong-version")); assertThrows(IOException.class, () -> worlds.setClientProfile(world.id, "wrong-loader")); + assertThrows(IOException.class, () -> worlds.create("client world", client.id)); assertThrows(IllegalArgumentException.class, () -> worlds.create("../escape", "bad", server.id)); + } + @Test void rejectsMalformedAndEscapingMetadata() throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); WorldStore worlds = new WorldStore(profiles.getRoot(), profiles); + Profile source = profiles.create("plugins", Profile.Type.PLUGINS, "1.20.4", "PAPER", false); WorldDefinition world = worlds.create("world", source.id); + Path definition = profiles.getRoot().resolve("worlds/definitions/" + world.id + ".properties"); Files.write(definition, Collections.singletonList("id=world")); + assertThrows(IOException.class, () -> worlds.get(world.id)); assertThrows(IllegalArgumentException.class, () -> worlds.get("../escape")); + } + @Test void rejectsKnownContradictoryContainersBeforeCreatingWorldsAndPreservesCustomHybridExecutables() throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("autoplug")); WorldStore worlds = new WorldStore(profiles.getRoot(), profiles); + for (String loader : Arrays.asList("FABRIC", "QUILT", "FORGE", "NEOFORGE")) { + Profile profile = profiles.create("plugins-" + loader, Profile.Type.PLUGINS, "1.20.4", loader, false); + IOException failure = assertThrows(IOException.class, () -> worlds.create("invalid-" + loader, profile.id)); assertTrue(failure.getMessage().contains("MODS_SERVER")); + } + for (String loader : Arrays.asList("PAPER", "PURPUR", "SPIGOT", "BUKKIT")) { + Profile profile = profiles.create("mods-" + loader, Profile.Type.MODS_SERVER, "1.20.4", loader, false); + IOException failure = assertThrows(IOException.class, () -> worlds.create("invalid-" + loader, profile.id)); assertTrue(failure.getMessage().contains("PLUGINS")); + } + assertTrue(worlds.list().isEmpty()); + Path supplied = WorldServerPreparerTest.fixtureJar(temporary, WorldFixtureProcesses.class.getName(), "1.20.4"); + for (Profile.Type type : Arrays.asList(Profile.Type.PLUGINS, Profile.Type.MODS_SERVER)) { + Profile profile = profiles.create("hybrid-" + type.name(), type, "1.20.4", "MOHIST", false); WorldDefinition world = worlds.create("hybrid-" + type.name(), profile.id); + assertEquals(supplied, worlds.setServerExecutable(world.id, supplied).serverExecutable); assertEquals(type, world.profileType); + } + assertEquals(2, worlds.list().size()); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapperTest.java b/src/test/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapperTest.java new file mode 100644 index 00000000..af58026f --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/network/UpnpPortMapperTest.java @@ -0,0 +1,94 @@ +package com.osiris.autoplug.client.worlds.network; + +import com.osiris.autoplug.client.worlds.VirtualWorldService; +import com.sun.net.httpserver.*; +import org.junit.jupiter.api.Test; +import org.w3c.dom.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class UpnpPortMapperTest { + @Test void realLoopbackSoapFlowAddsFiniteLeaseAndDeletesOnlyItsOwnMapping() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + VirtualWorldService.Mapping mapping = fixture.mapper(30, 10).map(25577); + assertEquals("8.8.4.4:25577", mapping.joinAddress()); assertEquals("30", fixture.entry.get("NewLeaseDuration")); assertEquals("TCP", fixture.entry.get("NewProtocol")); assertEquals("127.0.0.1", fixture.entry.get("NewInternalClient")); + assertTrue(fixture.entry.get("NewPortMappingDescription").startsWith("AutoPlug-")); assertEquals(1, fixture.adds.get()); + mapping.close(); mapping.close(); assertNull(fixture.entry); assertEquals(1, fixture.deletes.get()); assertThrows(IllegalStateException.class, mapping::joinAddress); + } + } + @Test void cgnatAndPrivateWanAddressesDoNotCreateRouterMappings() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + for (String address : new String[]{"100.64.1.2", "192.168.1.1", "0.0.0.0", "999.1.1.1"}) { fixture.external = address; assertThrows(IOException.class, () -> fixture.mapper(30, 10).map(25577)); } + assertEquals(0, fixture.adds.get()); assertEquals(0, fixture.deletes.get()); + } + } + @Test void existingOrReplacedMappingsAreNeverOverwrittenOrDeleted() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + fixture.entry = entry("Other-application"); assertThrows(IOException.class, () -> fixture.mapper(30, 10).map(25577)); assertEquals(0, fixture.adds.get()); assertEquals(0, fixture.deletes.get()); + fixture.entry = null; VirtualWorldService.Mapping mapping = fixture.mapper(30, 10).map(25577); fixture.entry = entry("Replacement"); + assertThrows(IOException.class, mapping::close); assertEquals("Replacement", fixture.entry.get("NewPortMappingDescription")); assertEquals(0, fixture.deletes.get()); + } + } + @Test void permanentLeaseIsRejectedAndOurUnexpectedMappingIsRemoved() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + fixture.permanentLease = true; assertThrows(IOException.class, () -> fixture.mapper(30, 10).map(25577)); assertEquals(1, fixture.adds.get()); assertEquals(1, fixture.deletes.get()); assertNull(fixture.entry); + } + } + @Test void untrustedDescriptionCannotRedirectControlOrReadExternalEntities() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + fixture.control = "http://8.8.8.8/elsewhere"; assertThrows(IOException.class, () -> fixture.mapper(30, 10).map(25577)); assertEquals(0, fixture.soapRequests.get()); + fixture.control = "/control"; fixture.badXml = true; assertThrows(IOException.class, () -> fixture.mapper(30, 10).map(25577)); assertEquals(0, fixture.soapRequests.get()); + } + assertThrows(Exception.class, () -> UpnpPortMapper.xml("]>&e;".getBytes(StandardCharsets.UTF_8))); + } + @Test void renewalFailureBecomesVisibleAndFiniteMappingStillGetsCleanedUp() throws Exception { + try (GatewayFixture fixture = new GatewayFixture()) { + VirtualWorldService.Mapping mapping = fixture.mapper(3, 1).map(25577); fixture.rejectRenewal = true; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(4); boolean failed = false; + while (System.nanoTime() < deadline) { try { mapping.joinAddress(); } catch (IllegalStateException renewalFailed) { failed = true; break; } Thread.sleep(25); } + assertTrue(failed, "The renewal failure must be visible instead of retaining a stale public address"); mapping.close(); assertEquals(1, fixture.deletes.get()); + } + } + @Test void emptyDiscoveryAndInvalidPortFailBeforeAnyControlRequest() { + UpnpPortMapper mapper = new UpnpPortMapper(Collections::emptyList, 30, 10); + assertThrows(IllegalArgumentException.class, () -> mapper.map(0)); assertTrue(assertThrows(IOException.class, () -> mapper.map(25577)).getMessage().contains("no compatible local UPnP gateway")); + } + private static Map entry(String name) { Map values = new LinkedHashMap<>(); values.put("NewInternalPort", "25577"); values.put("NewInternalClient", "127.0.0.1"); values.put("NewEnabled", "1"); values.put("NewPortMappingDescription", name); values.put("NewLeaseDuration", "30"); return values; } + private static final class GatewayFixture implements AutoCloseable { + final HttpServer server; final InetAddress loopback; final AtomicInteger adds = new AtomicInteger(), deletes = new AtomicInteger(), soapRequests = new AtomicInteger(); + volatile Map entry; volatile String external = "8.8.4.4", control = "/control"; volatile boolean permanentLease, rejectRenewal, badXml; + GatewayFixture() throws Exception { + loopback = InetAddress.getByName("127.0.0.1"); server = HttpServer.create(new InetSocketAddress(loopback, 0), 0); + server.createContext("/description", exchange -> reply(exchange, 200, badXml ? "]>&e;" : "urn:schemas-upnp-org:service:WANIPConnection:1" + control + "")); + server.createContext("/control", exchange -> { + soapRequests.incrementAndGet(); + try { + String header = exchange.getRequestHeaders().getFirst("SOAPAction"); String action = header.substring(header.indexOf('#') + 1).replace("\"", ""); + assertEquals("POST", exchange.getRequestMethod()); Document request = UpnpPortMapper.xml(exchange.getRequestBody().readAllBytes()); + Map result = new LinkedHashMap<>(); + if (action.equals("GetExternalIPAddress")) result.put("NewExternalIPAddress", external); + else if (action.equals("GetSpecificPortMappingEntry")) { if (entry == null) { fault(exchange, 714); return; } result.putAll(entry); } + else if (action.equals("AddPortMapping")) { + if (rejectRenewal && adds.get() > 0) { fault(exchange, 718); return; } + Map newEntry = new LinkedHashMap<>(); NodeList nodes = request.getElementsByTagNameNS("urn:schemas-upnp-org:service:WANIPConnection:1", action); NodeList arguments = nodes.item(0).getChildNodes(); + for (int i = 0; i < arguments.getLength(); i++) if (arguments.item(i) instanceof Element) newEntry.put(arguments.item(i).getNodeName(), arguments.item(i).getTextContent()); + if (permanentLease) newEntry.put("NewLeaseDuration", "0"); entry = newEntry; adds.incrementAndGet(); + } else if (action.equals("DeletePortMapping")) { entry = null; deletes.incrementAndGet(); } + else throw new IOException("Unexpected fixture SOAP action: " + action); + StringBuilder response = new StringBuilder(""); + for (Map.Entry value : result.entrySet()) response.append('<').append(value.getKey()).append('>').append(value.getValue()).append("'); + reply(exchange, 200, response.append("").toString()); + } catch (Throwable error) { reply(exchange, 500, "" + error.getClass().getSimpleName() + ""); } + }); server.start(); + } + UpnpPortMapper mapper(int lease, int renewal) { return new UpnpPortMapper(() -> Collections.singletonList(new UpnpPortMapper.Gateway(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/description"), loopback, loopback)), lease, renewal); } + private static void fault(HttpExchange exchange, int code) throws IOException { reply(exchange, 500, "" + code + "Fixture failure"); } + private static void reply(HttpExchange exchange, int status, String text) throws IOException { byte[] bytes = text.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/xml"); exchange.sendResponseHeaders(status, bytes.length); try (OutputStream output = exchange.getResponseBody()) { output.write(bytes); } } + @Override public void close() { server.stop(0); } + } +} diff --git a/src/test/resources/native-launcher/1.12.2.json b/src/test/resources/native-launcher/1.12.2.json new file mode 100644 index 00000000..3e07b18b --- /dev/null +++ b/src/test/resources/native-launcher/1.12.2.json @@ -0,0 +1 @@ +{"assetIndex": {"id": "1.12", "sha1": "a21e1ded1a24ea1548dd8db0cf30b6acb02655a9", "size": 143136, "totalSize": 129843781, "url": "https://piston-meta.mojang.com/v1/packages/a21e1ded1a24ea1548dd8db0cf30b6acb02655a9/1.12.json"}, "assets": "1.12", "complianceLevel": 0, "downloads": {"client": {"sha1": "0f275bc1547d01fa5f56ba34bdc87d981ee12daf", "size": 10180113, "url": "https://piston-data.mojang.com/v1/objects/0f275bc1547d01fa5f56ba34bdc87d981ee12daf/client.jar"}, "server": {"sha1": "886945bfb2b978778c3a0288fd7fab09d315b25f", "size": 30222121, "url": "https://piston-data.mojang.com/v1/objects/886945bfb2b978778c3a0288fd7fab09d315b25f/server.jar"}}, "id": "1.12.2", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}, "libraries": [{"downloads": {"artifact": {"path": "com/mojang/patchy/1.3.9/patchy-1.3.9.jar", "sha1": "eb8bb7b66fa0e2152b1b40b3856e82f7619439ee", "size": 23581, "url": "https://libraries.minecraft.net/com/mojang/patchy/1.3.9/patchy-1.3.9.jar"}}, "name": "com.mojang:patchy:1.3.9"}, {"downloads": {"artifact": {"path": "oshi-project/oshi-core/1.1/oshi-core-1.1.jar", "sha1": "9ddf7b048a8d701be231c0f4f95fd986198fd2d8", "size": 30973, "url": "https://libraries.minecraft.net/oshi-project/oshi-core/1.1/oshi-core-1.1.jar"}}, "name": "oshi-project:oshi-core:1.1"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/4.4.0/jna-4.4.0.jar", "sha1": "cb208278274bf12ebdb56c61bd7407e6f774d65a", "size": 1091208, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/4.4.0/jna-4.4.0.jar"}}, "name": "net.java.dev.jna:jna:4.4.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar", "sha1": "e3f70017be8100d3d6923f50b3d2ee17714e9c13", "size": 913436, "url": "https://libraries.minecraft.net/net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar"}}, "name": "net.java.dev.jna:platform:3.4.0"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j-core-mojang/51.2/icu4j-core-mojang-51.2.jar", "sha1": "63d216a9311cca6be337c1e458e587f99d382b84", "size": 1634692, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j-core-mojang/51.2/icu4j-core-mojang-51.2.jar"}}, "name": "com.ibm.icu:icu4j-core-mojang:51.2"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar", "sha1": "cdd846cfc4e0f7eefafc02c0f5dce32b9303aa2a", "size": 78175, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.3"}, {"downloads": {"artifact": {"path": "com/paulscode/codecjorbis/20101023/codecjorbis-20101023.jar", "sha1": "c73b5636faf089d9f00e8732a829577de25237ee", "size": 103871, "url": "https://libraries.minecraft.net/com/paulscode/codecjorbis/20101023/codecjorbis-20101023.jar"}}, "name": "com.paulscode:codecjorbis:20101023"}, {"downloads": {"artifact": {"path": "com/paulscode/codecwav/20101023/codecwav-20101023.jar", "sha1": "12f031cfe88fef5c1dd36c563c0a3a69bd7261da", "size": 5618, "url": "https://libraries.minecraft.net/com/paulscode/codecwav/20101023/codecwav-20101023.jar"}}, "name": "com.paulscode:codecwav:20101023"}, {"downloads": {"artifact": {"path": "com/paulscode/libraryjavasound/20101123/libraryjavasound-20101123.jar", "sha1": "5c5e304366f75f9eaa2e8cca546a1fb6109348b3", "size": 21679, "url": "https://libraries.minecraft.net/com/paulscode/libraryjavasound/20101123/libraryjavasound-20101123.jar"}}, "name": "com.paulscode:libraryjavasound:20101123"}, {"downloads": {"artifact": {"path": "com/paulscode/librarylwjglopenal/20100824/librarylwjglopenal-20100824.jar", "sha1": "73e80d0794c39665aec3f62eee88ca91676674ef", "size": 18981, "url": "https://libraries.minecraft.net/com/paulscode/librarylwjglopenal/20100824/librarylwjglopenal-20100824.jar"}}, "name": "com.paulscode:librarylwjglopenal:20100824"}, {"downloads": {"artifact": {"path": "com/paulscode/soundsystem/20120107/soundsystem-20120107.jar", "sha1": "419c05fe9be71f792b2d76cfc9b67f1ed0fec7f6", "size": 65020, "url": "https://libraries.minecraft.net/com/paulscode/soundsystem/20120107/soundsystem-20120107.jar"}}, "name": "com.paulscode:soundsystem:20120107"}, {"downloads": {"artifact": {"path": "io/netty/netty-all/4.1.9.Final/netty-all-4.1.9.Final.jar", "sha1": "0097860965d6a0a6b98e7f569f3f966727b8db75", "size": 3511093, "url": "https://libraries.minecraft.net/io/netty/netty-all/4.1.9.Final/netty-all-4.1.9.Final.jar"}}, "name": "io.netty:netty-all:4.1.9.Final"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/21.0/guava-21.0.jar", "sha1": "3a3d111be1be1b745edfa7d91678a12d7ed38709", "size": 2521113, "url": "https://libraries.minecraft.net/com/google/guava/guava/21.0/guava-21.0.jar"}}, "name": "com.google.guava:guava:21.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar", "sha1": "6c6c702c89bfff3cd9e80b04d668c5e190d588c6", "size": 479881, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar"}}, "name": "org.apache.commons:commons-lang3:3.5"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.5/commons-io-2.5.jar", "sha1": "2852e6e05fbb95076fc091f6d1780f1f8fe35e0f", "size": 208700, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.5/commons-io-2.5.jar"}}, "name": "commons-io:commons-io:2.5"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.10/commons-codec-1.10.jar", "sha1": "4b95f4897fa13f2cd904aee711aeafc0c5295cd8", "size": 284184, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.10/commons-codec-1.10.jar"}}, "name": "commons-codec:commons-codec:1.10"}, {"downloads": {"artifact": {"path": "net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar", "sha1": "39c7796b469a600f72380316f6b1f11db6c2c7c4", "size": 208338, "url": "https://libraries.minecraft.net/net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar"}}, "name": "net.java.jinput:jinput:2.0.5"}, {"downloads": {"artifact": {"path": "net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar", "sha1": "e12fe1fda814bd348c1579329c86943d2cd3c6a6", "size": 7508, "url": "https://libraries.minecraft.net/net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar"}}, "name": "net.java.jutils:jutils:1.0.0"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.8.0/gson-2.8.0.jar", "sha1": "c4ba5371a29ac9b2ad6129b1d39ea38750043eff", "size": 231952, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.8.0/gson-2.8.0.jar"}}, "name": "com.google.code.gson:gson:2.8.0"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/1.5.25/authlib-1.5.25.jar", "sha1": "9834cdf236c22e84b946bba989e2f94ef5897c3c", "size": 65621, "url": "https://libraries.minecraft.net/com/mojang/authlib/1.5.25/authlib-1.5.25.jar"}}, "name": "com.mojang:authlib:1.5.25"}, {"downloads": {"artifact": {"path": "com/mojang/realms/1.10.22/realms-1.10.22.jar", "sha1": "bd0dccebdf3744c75f1ca20063f16e8f7d5e663f", "size": 7135057, "url": "https://libraries.minecraft.net/com/mojang/realms/1.10.22/realms-1.10.22.jar"}}, "name": "com.mojang:realms:1.10.22"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar", "sha1": "a698750c16740fd5b3871425f4cb3bbaa87f529d", "size": 365552, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar"}}, "name": "org.apache.commons:commons-compress:1.8.1"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar", "sha1": "18f4247ff4572a074444572cee34647c43e7c9c7", "size": 589512, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.3.3"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar", "sha1": "f6f66e966c70a83ffbdb6f17a0919eaf7c8aca7f", "size": 62050, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar"}}, "name": "commons-logging:commons-logging:1.1.3"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar", "sha1": "31fbbff1ddbf98f3aa7377c94d33b0447c646b6e", "size": 282269, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.3.2"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/7.1.0/fastutil-7.1.0.jar", "sha1": "9835253257524c1be7ab50c057aa2d418fb72082", "size": 17655579, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/7.1.0/fastutil-7.1.0.jar"}}, "name": "it.unimi.dsi:fastutil:7.1.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.8.1/log4j-api-2.8.1.jar", "sha1": "e801d13612e22cad62a3f4f3fe7fdbe6334a8e72", "size": 228859, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.8.1/log4j-api-2.8.1.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.8.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.8.1/log4j-core-2.8.1.jar", "sha1": "4ac28ff2f1ddf05dae3043a190451e8c46b73c31", "size": 1402925, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.8.1/log4j-core-2.8.1.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.8.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/lwjgl/2.9.4-nightly-20150209/lwjgl-2.9.4-nightly-20150209.jar", "sha1": "697517568c68e78ae0b4544145af031c81082dfe", "size": 1047168, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl/2.9.4-nightly-20150209/lwjgl-2.9.4-nightly-20150209.jar"}}, "name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/lwjgl_util/2.9.4-nightly-20150209/lwjgl_util-2.9.4-nightly-20150209.jar", "sha1": "d51a7c040a721d13efdfbd34f8b257b2df882ad0", "size": 173887, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl_util/2.9.4-nightly-20150209/lwjgl_util-2.9.4-nightly-20150209.jar"}}, "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar", "sha1": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33", "size": 22, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar", "sha1": "931074f46c795d2f7b30ed6395df5715cfd7675b", "size": 578680, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar"}, "natives-osx": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar", "sha1": "bcab850f8f487c3f4c4dbabde778bb82bd1a40ed", "size": 426822, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar", "sha1": "b84d5102b9dbfabfeb5e43c7e2828d98a7fc80e0", "size": 613748, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", "natives": {"linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/lwjgl/2.9.2-nightly-20140822/lwjgl-2.9.2-nightly-20140822.jar", "sha1": "7707204c9ffa5d91662de95f0a224e2f721b22af", "size": 1045632, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl/2.9.2-nightly-20140822/lwjgl-2.9.2-nightly-20140822.jar"}}, "name": "org.lwjgl.lwjgl:lwjgl:2.9.2-nightly-20140822", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/lwjgl_util/2.9.2-nightly-20140822/lwjgl_util-2.9.2-nightly-20140822.jar", "sha1": "f0e612c840a7639c1f77f68d72a28dae2f0c8490", "size": 173887, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl_util/2.9.2-nightly-20140822/lwjgl_util-2.9.2-nightly-20140822.jar"}}, "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.2-nightly-20140822", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-linux.jar", "sha1": "d898a33b5d0a6ef3fed3a4ead506566dce6720a5", "size": 578539, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-linux.jar"}, "natives-osx": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-osx.jar", "sha1": "79f5ce2fea02e77fe47a3c745219167a542121d7", "size": 468116, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-osx.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-windows.jar", "sha1": "78b2a55ce4dc29c6b3ec4df8ca165eba05f9b341", "size": 613680, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.2-nightly-20140822", "natives": {"linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"classifiers": {"natives-linux": {"path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-linux.jar", "sha1": "7ff832a6eb9ab6a767f1ade2b548092d0fa64795", "size": 10362, "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-linux.jar"}, "natives-osx": {"path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-osx.jar", "sha1": "53f9c919f34d2ca9de8c51fc4e1e8282029a9232", "size": 12186, "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-osx.jar"}, "natives-windows": {"path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-windows.jar", "sha1": "385ee093e01f587f30ee1c8a2ee7d408fd732e16", "size": 155179, "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "net.java.jinput:jinput-platform:2.0.5", "natives": {"linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows"}}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.10.3/text2speech-1.10.3.jar", "sha1": "48fd510879dff266c3815947de66e3d4809f8668", "size": 11055, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.10.3/text2speech-1.10.3.jar"}}, "name": "com.mojang:text2speech:1.10.3"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.10.3/text2speech-1.10.3.jar", "sha1": "48fd510879dff266c3815947de66e3d4809f8668", "size": 11055, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.10.3/text2speech-1.10.3.jar"}, "classifiers": {"natives-linux": {"path": "com/mojang/text2speech/1.10.3/text2speech-1.10.3-natives-linux.jar", "sha1": "ab7896aec3b3dd272b06194357f2d98f832c0cfc", "size": 7833, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.10.3/text2speech-1.10.3-natives-linux.jar"}, "natives-windows": {"path": "com/mojang/text2speech/1.10.3/text2speech-1.10.3-natives-windows.jar", "sha1": "84a4b856389cc4f485275b1f63497a95a857a443", "size": 81217, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.10.3/text2speech-1.10.3-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "com.mojang:text2speech:1.10.3", "natives": {"linux": "natives-linux", "windows": "natives-windows"}}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}, "classifiers": {"natives-osx": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar", "sha1": "08befab4894d55875f33c3d300f4f71e6e828f64", "size": 5629, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "ca.weblite:java-objc-bridge:1.0.0", "natives": {"osx": "natives-osx"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}}, "name": "ca.weblite:java-objc-bridge:1.0.0", "rules": [{"action": "allow", "os": {"name": "osx"}}]}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", "size": 888, "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} --assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} --accessToken ${auth_access_token} --userType ${user_type} --versionType ${version_type}", "minimumLauncherVersion": 18, "releaseTime": "2017-09-18T08:39:46+00:00", "time": "2017-09-18T08:39:46+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/1.19.4.json b/src/test/resources/native-launcher/1.19.4.json new file mode 100644 index 00000000..48f0b610 --- /dev/null +++ b/src/test/resources/native-launcher/1.19.4.json @@ -0,0 +1 @@ +{"arguments": {"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--clientId", "${clientid}", "--xuid", "${auth_xuid}", "--userType", "${user_type}", "--versionType", "${version_type}", {"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"}, {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}], "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, {"rules": [{"action": "allow", "os": {"name": "windows"}}], "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"}, {"rules": [{"action": "allow", "os": {"name": "windows", "version": "^10\\."}}], "value": ["-Dos.name=Windows 10", "-Dos.version=10.0"]}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "3", "sha1": "7eefc53baad3fe4a5af9f03d767f60c75cc044b6", "size": 411679, "totalSize": 593650997, "url": "https://piston-meta.mojang.com/v1/packages/7eefc53baad3fe4a5af9f03d767f60c75cc044b6/3.json"}, "assets": "3", "complianceLevel": 1, "downloads": {"client": {"sha1": "958928a560c9167687bea0cefeb7375da1e552a8", "size": 23476620, "url": "https://piston-data.mojang.com/v1/objects/958928a560c9167687bea0cefeb7375da1e552a8/client.jar"}, "client_mappings": {"sha1": "f14771b764f943c154d3a6fcb47694477e328148", "size": 7844191, "url": "https://piston-data.mojang.com/v1/objects/f14771b764f943c154d3a6fcb47694477e328148/client.txt"}, "server": {"sha1": "8f3112a1049751cc472ec13e397eade5336ca7ae", "size": 47515675, "url": "https://piston-data.mojang.com/v1/objects/8f3112a1049751cc472ec13e397eade5336ca7ae/server.jar"}, "server_mappings": {"sha1": "73c8bb982e420b33aad9632b482608c5c33e2d13", "size": 6043763, "url": "https://piston-data.mojang.com/v1/objects/73c8bb982e420b33aad9632b482608c5c33e2d13/server.txt"}}, "id": "1.19.4", "javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17}, "libraries": [{"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", "size": 1330045, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar"}}, "name": "ca.weblite:java-objc-bridge:1.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/6.2.2/oshi-core-6.2.2.jar", "sha1": "54f5efc19bca95d709d9a37d19ffcbba3d21c1a6", "size": 947865, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.2.2/oshi-core-6.2.2.jar"}}, "name": "com.github.oshi:oshi-core:6.2.2"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.10/gson-2.10.jar", "sha1": "dd9b193aef96e973d5a11ab13cd17430c2e4306b", "size": 286235, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10/gson-2.10.jar"}}, "name": "com.google.code.gson:gson:2.10"}, {"downloads": {"artifact": {"path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", "size": 4617, "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}, "name": "com.google.guava:failureaccess:1.0.1"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", "sha1": "60458f877d055d0c9114d9e1a2efb737b4bc282c", "size": 2959479, "url": "https://libraries.minecraft.net/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}, "name": "com.google.guava:guava:31.1-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/71.1/icu4j-71.1.jar", "sha1": "9e7d3304c23f9ba5cb71915f7cce23231a57a445", "size": 13963762, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/71.1/icu4j-71.1.jar"}}, "name": "com.ibm.icu:icu4j:71.1"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/3.18.38/authlib-3.18.38.jar", "sha1": "016106b26bce62bda55bab42785b999e44d77ecb", "size": 118354, "url": "https://libraries.minecraft.net/com/mojang/authlib/3.18.38/authlib-3.18.38.jar"}}, "name": "com.mojang:authlib:3.18.38"}, {"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar"}}, "name": "com.mojang:blocklist:1.0.10"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar", "sha1": "c1ef1234282716483c92183f49bef47b1a89bfa9", "size": 77116, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"}}, "name": "com.mojang:brigadier:1.0.18"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/6.0.6/datafixerupper-6.0.6.jar", "sha1": "e38e20946530646e866db03b2b192883d0ea6e84", "size": 689986, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/6.0.6/datafixerupper-6.0.6.jar"}}, "name": "com.mojang:datafixerupper:6.0.6"}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.1.1/logging-1.1.1.jar", "sha1": "832b8e6674a9b325a5175a3a6267dfaf34c85139", "size": 15343, "url": "https://libraries.minecraft.net/com/mojang/logging/1.1.1/logging-1.1.1.jar"}}, "name": "com.mojang:logging:1.1.1"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", "size": 4439, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar"}}, "name": "com.mojang:patchy:2.2.10"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.16.7/text2speech-1.16.7.jar", "sha1": "ee4095669061d1fe4bce5fea23d69d1520bc2d58", "size": 12279, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.16.7/text2speech-1.16.7.jar"}}, "name": "com.mojang:text2speech:1.16.7"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.15/commons-codec-1.15.jar", "sha1": "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d", "size": 353793, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.15/commons-codec-1.15.jar"}}, "name": "commons-codec:commons-codec:1.15"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", "sha1": "a2503f302b11ebde7ebc3df41daebe0e4eea3689", "size": 327135, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar"}}, "name": "commons-io:commons-io:2.11.0"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.2/commons-logging-1.2.jar", "sha1": "4bfc12adfe4842bf07b657f0369c4cb522955686", "size": 61829, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}, "name": "commons-logging:commons-logging:1.2"}, {"downloads": {"artifact": {"path": "io/netty/netty-buffer/4.1.82.Final/netty-buffer-4.1.82.Final.jar", "sha1": "a544270cf1ae8b8077082f5036436a9a9971ea71", "size": 304664, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.82.Final/netty-buffer-4.1.82.Final.jar"}}, "name": "io.netty:netty-buffer:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec/4.1.82.Final/netty-codec-4.1.82.Final.jar", "sha1": "b77200379acb345a9ffdece1c605e591ac3e4e0a", "size": 339155, "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.82.Final/netty-codec-4.1.82.Final.jar"}}, "name": "io.netty:netty-codec:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.1.82.Final/netty-common-4.1.82.Final.jar", "sha1": "022d148e85c3f5ebdacc0ce1f5aabb1d420f73f3", "size": 653880, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.82.Final/netty-common-4.1.82.Final.jar"}}, "name": "io.netty:netty-common:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.1.82.Final/netty-handler-4.1.82.Final.jar", "sha1": "644041d1fa96a5d3130a29e8978630d716d76e38", "size": 538569, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.82.Final/netty-handler-4.1.82.Final.jar"}}, "name": "io.netty:netty-handler:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.1.82.Final/netty-resolver-4.1.82.Final.jar", "sha1": "38f665ae8dcd29032eea31245ba7806bed2e0fa8", "size": 37776, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.82.Final/netty-resolver-4.1.82.Final.jar"}}, "name": "io.netty:netty-resolver:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.1.82.Final/netty-transport-classes-epoll-4.1.82.Final.jar", "sha1": "e7c7dd18deac93105797f30057c912651ea76521", "size": 142066, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.82.Final/netty-transport-classes-epoll-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-aarch_64.jar", "sha1": "476409d6255001ca53a55f65b01c13822f8dc93a", "size": 39489, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.82.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-x86_64.jar", "sha1": "c7350a71920f3ae9142945e25fed4846cce53374", "size": 37922, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.82.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.1.82.Final/netty-transport-native-unix-common-4.1.82.Final.jar", "sha1": "3e895b35ca1b8a0eca56cacff4c2dde5d2c6abce", "size": 43684, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.82.Final/netty-transport-native-unix-common-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.1.82.Final/netty-transport-4.1.82.Final.jar", "sha1": "e431a218d91acb6476ccad5f5aafde50aa3945ca", "size": 485752, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.82.Final/netty-transport-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport:4.1.82.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.9/fastutil-8.5.9.jar", "sha1": "bb7ea75ecdb216654237830b3a96d87ad91f8cc5", "size": 23376043, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.9/fastutil-8.5.9.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.9"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.12.1/jna-platform-5.12.1.jar", "sha1": "097406a297c852f4a41e688a176ec675f72e8329", "size": 1356627, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.12.1/jna-platform-5.12.1.jar"}}, "name": "net.java.dev.jna:jna-platform:5.12.1"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.12.1/jna-5.12.1.jar", "sha1": "b1e93a735caea94f503e95e6fe79bf9cdc1e985d", "size": 1866196, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.12.1/jna-5.12.1.jar"}}, "name": "net.java.dev.jna:jna:5.12.1"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", "size": 78146, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.4"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar", "sha1": "4ec95b60d4e86b5c95a0e919cb172a0af98011ef", "size": 1018316, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar"}}, "name": "org.apache.commons:commons-compress:1.21"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar", "sha1": "c6842c86792ff03b9f1d1fe2aab8dc23aa6c6f0e", "size": 587402, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.12.0"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", "sha1": "e5f6cae5ca7ecaac1ec2827a9e2d65ae2869cada", "size": 780321, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.5.13"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.4.15/httpcore-4.4.15.jar", "sha1": "7f2e0c573eaa7a74bac2e89b359e1f73d92a0a1d", "size": 328324, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.15/httpcore-4.4.15.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.4.15"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar", "sha1": "ea1b37f38c327596b216542bc636cfdc0b8036fa", "size": 317566, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar", "sha1": "3b6eeb4de4c49c0fe38a4ee27188ff5fee44d0bb", "size": 1864386, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar", "sha1": "5c04bfdd63ce9dceb2e284b81e96b6a70010ee10", "size": 27721, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.19.0"}, {"downloads": {"artifact": {"path": "org/joml/joml/1.10.5/joml-1.10.5.jar", "sha1": "22566d58af70ad3d72308bab63b8339906deb649", "size": 712082, "url": "https://libraries.minecraft.net/org/joml/joml/1.10.5/joml-1.10.5.jar"}}, "name": "org.joml:joml:1.10.5"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1.jar", "sha1": "cbac1b8d30cb4795149c1ef540f912671a8616d0", "size": 128801, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-linux.jar", "sha1": "81716978214ecbda15050ca394b06ef61501a49e", "size": 119817, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos.jar", "sha1": "9ec4ce1fc8c85fdef03ef4ff2aace6f5775fb280", "size": 131655, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos-arm64.jar", "sha1": "cac0d3f712a3da7641fa174735a5f315de7ffe0a", "size": 129077, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows.jar", "sha1": "ed892f945cf7e79c8756796f32d00fa4ceaf573b", "size": 145512, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-arm64.jar", "sha1": "beda65ee503443e60aa196d58ed31f8d001dc22a", "size": 123808, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-x86.jar", "sha1": "b997e3391d6ce8f05487e7335d95c606043884a1", "size": 139251, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1.jar", "sha1": "a817bcf213db49f710603677457567c37d53e103", "size": 36601, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-linux.jar", "sha1": "33dbb017b6ed6b25f993ad9d56741a49e7937718", "size": 166524, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos.jar", "sha1": "56424dc8db3cfb8e7b594aa6d59a4f4387b7f544", "size": 117480, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos-arm64.jar", "sha1": "e577b87d8ad2ade361aaea2fcf226c660b15dee8", "size": 103475, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows.jar", "sha1": "948a89b76a16aa324b046ae9308891216ffce5f9", "size": 135585, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-arm64.jar", "sha1": "cae85c4edb219c88b6a0c26a87955ad98dc9519d", "size": 114250, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-x86.jar", "sha1": "fb476c8ec110e1c137ad3ce8a7f7bfe6b11c6324", "size": 110405, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1.jar", "sha1": "2623a6b8ae1dfcd880738656a9f0243d2e6840bd", "size": 88237, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-linux.jar", "sha1": "f906b6439f6daa66001182ea7727e3467a93681b", "size": 476825, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos.jar", "sha1": "3a57b8911835fb58b5e558d0ca0d28157e263d45", "size": 397196, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos-arm64.jar", "sha1": "23d55e7490b57495320f6c9e1936d78fd72c4ef8", "size": 346125, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows.jar", "sha1": "30a474d0e57193d7bc128849a3ab66bc9316fdb1", "size": 576872, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-arm64.jar", "sha1": "40d65f1a7368a2aa47336f9cb69f5a190cf9975a", "size": 505234, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-x86.jar", "sha1": "888349f7b1be6fbae58bf8edfb9ef12def04c4e3", "size": 520313, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1.jar", "sha1": "831a5533a21a5f4f81bbc51bb13e9899319b5411", "size": 921563, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-linux.jar", "sha1": "ab9ab6fde3743e3550fa5d46d785ecb45b047d99", "size": 79125, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos.jar", "sha1": "a0d12697ea019a7362eff26475b0531340e876a6", "size": 40709, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos-arm64.jar", "sha1": "eafe34b871d966292e8db0f1f3d6b8b110d4e91d", "size": 41665, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows.jar", "sha1": "c1807e9bd571402787d7e37e3029776ae2513bb8", "size": 100205, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-arm64.jar", "sha1": "527d78f1e9056aff3ed02ce93019c73c5e8f1721", "size": 82445, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-x86.jar", "sha1": "deef3eb9b178ff2ff3ce893cc72ae741c3a17974", "size": 87463, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1.jar", "sha1": "b119297cf8ed01f247abe8685857f8e7fcf5980f", "size": 112380, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-linux.jar", "sha1": "3ee7aec8686e52867677110415566a5342a80230", "size": 227237, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos.jar", "sha1": "def8879b8d38a47a4cc1d48b1f9a7b772e51258e", "size": 203582, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos-arm64.jar", "sha1": "fcf073ed911752abdca5f0b00a53cfdf17ff8e8b", "size": 178408, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows.jar", "sha1": "86315914ac119efdb02dc9e8e978ade84f1702af", "size": 256301, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-arm64.jar", "sha1": "fde63cdd2605c00636721a6c8b961e41d1f6b247", "size": 216848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-x86.jar", "sha1": "a8d41f419eecb430b7c91ea2ce2c5c451cae2091", "size": 225147, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1.jar", "sha1": "0ff1914111ef2e3e0110ef2dabc8d8cdaad82347", "size": 6767, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-linux.jar", "sha1": "a35110b9471bea8cde826ab297550ee8c22f5035", "size": 45389, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos.jar", "sha1": "78641a0fa5e9afa714adfdd152c357930c97a1ce", "size": 44821, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos-arm64.jar", "sha1": "972ecc17bad3571e81162153077b4d47b7b9eaa9", "size": 41380, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows.jar", "sha1": "a5d830475ec0958d9fdba1559efa99aef211e6ff", "size": 127930, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-arm64.jar", "sha1": "83a5e780df610829ff3a737822b4f931cffecd91", "size": 109139, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-x86.jar", "sha1": "842eedd876fae354abc308c98a263f6bbc9e8a4d", "size": 110042, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", "sha1": "ae58664f88e18a9bb2c77b063833ca7aaec484cb", "size": 724243, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar", "sha1": "1de885aba434f934201b99f2f1afb142036ac189", "size": 110704, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos.jar", "sha1": "fc6bb723dec2cd031557dccb2a95f0ab80acb9db", "size": 55706, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos-arm64.jar", "sha1": "71d0d5e469c9c95351eb949064497e3391616ac9", "size": 42693, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "0036c37f16ab611b3aa11f3bcf80b1d509b4ce6b", "size": 159361, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-arm64.jar", "sha1": "0f46cadcf95675908fd3a550d63d9d709cb68998", "size": 130064, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-x86.jar", "sha1": "3b14f4beae9dd39791ec9e12190a9380cd8a3ce6", "size": 134695, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.1/slf4j-api-2.0.1.jar", "sha1": "f48d81adce2abf5ad3cfe463df517952749e03bc", "size": 61388, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.1/slf4j-api-2.0.1.jar"}}, "name": "org.slf4j:slf4j-api:2.0.1"}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", "size": 888, "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minimumLauncherVersion": 21, "releaseTime": "2023-03-14T12:56:18+00:00", "time": "2023-03-14T12:56:18+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/1.20.4.json b/src/test/resources/native-launcher/1.20.4.json new file mode 100644 index 00000000..51eb8b07 --- /dev/null +++ b/src/test/resources/native-launcher/1.20.4.json @@ -0,0 +1 @@ +{"arguments": {"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--clientId", "${clientid}", "--xuid", "${auth_xuid}", "--userType", "${user_type}", "--versionType", "${version_type}", {"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"}, {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}, {"rules": [{"action": "allow", "features": {"has_quick_plays_support": true}}], "value": ["--quickPlayPath", "${quickPlayPath}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_singleplayer": true}}], "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_realms": true}}], "value": ["--quickPlayRealms", "${quickPlayRealms}"]}], "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, {"rules": [{"action": "allow", "os": {"name": "windows"}}], "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Djna.tmpdir=${natives_directory}", "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}", "-Dio.netty.native.workdir=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "12", "sha1": "16f8e2eca81b5c757d3870c17a58376fc296e2e0", "size": 437886, "totalSize": 655515325, "url": "https://piston-meta.mojang.com/v1/packages/16f8e2eca81b5c757d3870c17a58376fc296e2e0/12.json"}, "assets": "12", "complianceLevel": 1, "downloads": {"client": {"sha1": "fd19469fed4a4b4c15b2d5133985f0e3e7816a8a", "size": 24445539, "url": "https://piston-data.mojang.com/v1/objects/fd19469fed4a4b4c15b2d5133985f0e3e7816a8a/client.jar"}, "client_mappings": {"sha1": "be76ecc174ea25580bdc9bf335481a5192d9f3b7", "size": 8897012, "url": "https://piston-data.mojang.com/v1/objects/be76ecc174ea25580bdc9bf335481a5192d9f3b7/client.txt"}, "server": {"sha1": "8dd1a28015f51b1803213892b50b7b4fc76e594d", "size": 49150256, "url": "https://piston-data.mojang.com/v1/objects/8dd1a28015f51b1803213892b50b7b4fc76e594d/server.jar"}, "server_mappings": {"sha1": "c1cafe916dd8b58ed1fe0564fc8f786885224e62", "size": 6797462, "url": "https://piston-data.mojang.com/v1/objects/c1cafe916dd8b58ed1fe0564fc8f786885224e62/server.txt"}}, "id": "1.20.4", "javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17}, "libraries": [{"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", "size": 1330045, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar"}}, "name": "ca.weblite:java-objc-bridge:1.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/6.4.5/oshi-core-6.4.5.jar", "sha1": "943ba26de047eb6b28fff47f5ee939a34eb5fc8e", "size": 970546, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.4.5/oshi-core-6.4.5.jar"}}, "name": "com.github.oshi:oshi-core:6.4.5"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", "size": 283367, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar"}}, "name": "com.google.code.gson:gson:2.10.1"}, {"downloads": {"artifact": {"path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", "size": 4617, "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}, "name": "com.google.guava:failureaccess:1.0.1"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", "sha1": "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318", "size": 3041591, "url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar"}}, "name": "com.google.guava:guava:32.1.2-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/73.2/icu4j-73.2.jar", "sha1": "61ad4ef7f9131fcf6d25c34b817f90d6da06c9e9", "size": 14567819, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/73.2/icu4j-73.2.jar"}}, "name": "com.ibm.icu:icu4j:73.2"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/6.0.52/authlib-6.0.52.jar", "sha1": "65085e2eb921c4a3ebdde3d248637f1776e6d80d", "size": 115471, "url": "https://libraries.minecraft.net/com/mojang/authlib/6.0.52/authlib-6.0.52.jar"}}, "name": "com.mojang:authlib:6.0.52"}, {"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar"}}, "name": "com.mojang:blocklist:1.0.10"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar", "sha1": "73e324f2ee541493a5179abf367237faa782ed21", "size": 79955, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar"}}, "name": "com.mojang:brigadier:1.2.9"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/6.0.8/datafixerupper-6.0.8.jar", "sha1": "3ba4a30557a9b057760af4011f909ba619fc5125", "size": 689960, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/6.0.8/datafixerupper-6.0.8.jar"}}, "name": "com.mojang:datafixerupper:6.0.8"}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.1.1/logging-1.1.1.jar", "sha1": "832b8e6674a9b325a5175a3a6267dfaf34c85139", "size": 15343, "url": "https://libraries.minecraft.net/com/mojang/logging/1.1.1/logging-1.1.1.jar"}}, "name": "com.mojang:logging:1.1.1"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", "size": 4439, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar"}}, "name": "com.mojang:patchy:2.2.10"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar", "sha1": "3cad216e3a7f0c19b4b394388bc9ffc446f13b14", "size": 12243, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar"}}, "name": "com.mojang:text2speech:1.17.9"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar", "sha1": "4e3eb3d79888d76b54e28b350915b5dc3919c9de", "size": 360738, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar"}}, "name": "commons-codec:commons-codec:1.16.0"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.13.0/commons-io-2.13.0.jar", "sha1": "8bb2bc9b4df17e2411533a0708a69f983bf5e83b", "size": 483954, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.13.0/commons-io-2.13.0.jar"}}, "name": "commons-io:commons-io:2.13.0"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.2/commons-logging-1.2.jar", "sha1": "4bfc12adfe4842bf07b657f0369c4cb522955686", "size": 61829, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}, "name": "commons-logging:commons-logging:1.2"}, {"downloads": {"artifact": {"path": "io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar", "sha1": "f8f3d8644afa5e6e1a40a3a6aeb9d9aa970ecb4f", "size": 306590, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar"}}, "name": "io.netty:netty-buffer:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar", "sha1": "384ba4d75670befbedb45c4d3b497a93639c206d", "size": 345274, "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar"}}, "name": "io.netty:netty-codec:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar", "sha1": "7cceacaf11df8dc63f23d0fb58e9d4640fc88404", "size": 659930, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar", "sha1": "abb86c6906bf512bf2b797a41cd7d2e8d3cd7c36", "size": 560040, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar"}}, "name": "io.netty:netty-handler:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar", "sha1": "cec8348108dc76c47cf87c669d514be52c922144", "size": 37792, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar"}}, "name": "io.netty:netty-resolver:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar", "sha1": "795da37ded759e862457a82d9d92c4d39ce8ecee", "size": 147139, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar", "sha1": "5514744c588190ffda076b35a9b8c9f24946a960", "size": 40427, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar", "sha1": "54188f271e388e7f313aea995e82f58ce2cdb809", "size": 38954, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar", "sha1": "d469d84265ab70095b01b40886cabdd433b6e664", "size": 43897, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar", "sha1": "f37380d23c9bb079bc702910833b2fd532c9abd0", "size": 489624, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport:4.1.97.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar", "sha1": "c24946d46824bd528054bface3231d2ecb7e95e8", "size": 23326598, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.12"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar", "sha1": "88e9a306715e9379f3122415ef4ae759a352640d", "size": 1363209, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.13.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar", "sha1": "1200e7ebeedbe0d10062093f32925a912020e747", "size": 1879325, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar"}}, "name": "net.java.dev.jna:jna:5.13.0"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", "size": 78146, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.4"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.22/commons-compress-1.22.jar", "sha1": "691a8b4e6cf4248c3bc72c8b719337d5cb7359fa", "size": 1039712, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.22/commons-compress-1.22.jar"}}, "name": "org.apache.commons:commons-compress:1.22"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar", "sha1": "b7263237aa89c1f99b327197c41d0669707a462e", "size": 632267, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.13.0"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", "sha1": "e5f6cae5ca7ecaac1ec2827a9e2d65ae2869cada", "size": 780321, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.5.13"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar", "sha1": "51cf043c87253c9f58b539c9f7e44c8894223850", "size": 327891, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.4.16"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar", "sha1": "ea1b37f38c327596b216542bc636cfdc0b8036fa", "size": 317566, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar", "sha1": "3b6eeb4de4c49c0fe38a4ee27188ff5fee44d0bb", "size": 1864386, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar", "sha1": "5c04bfdd63ce9dceb2e284b81e96b6a70010ee10", "size": 27721, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.19.0"}, {"downloads": {"artifact": {"path": "org/joml/joml/1.10.5/joml-1.10.5.jar", "sha1": "22566d58af70ad3d72308bab63b8339906deb649", "size": 712082, "url": "https://libraries.minecraft.net/org/joml/joml/1.10.5/joml-1.10.5.jar"}}, "name": "org.joml:joml:1.10.5"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2.jar", "sha1": "757920418805fb90bfebb3d46b1d9e7669fca2eb", "size": 135828, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-linux.jar", "sha1": "0766bb0e8e829598b1c8052fd8173c62af741c52", "size": 115553, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos.jar", "sha1": "8223ba1b757c43624f03b09ab9d9228c7f6db001", "size": 140627, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos-arm64.jar", "sha1": "7e35644de1bf324ca9b7d52acd6e0b8d9a6da4ad", "size": 137535, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows.jar", "sha1": "01251e3cb7e5d6159334cfb9244f789ce992f03b", "size": 165947, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-arm64.jar", "sha1": "e79c4857a887bd79ba78bdf2d422a7d333028a2d", "size": 141892, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-x86.jar", "sha1": "17e1f9ec031ef72c2f7825c38eeb3a79c4d8bb17", "size": 156550, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2.jar", "sha1": "877e17e39ebcd58a9c956dc3b5b777813de0873a", "size": 43233, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-linux.jar", "sha1": "2bc33176cdfabf34a63df154b80914e8f433316b", "size": 204677, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos.jar", "sha1": "959ea96ea27cd1ee45943123140fdb55c49e961f", "size": 153695, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos-arm64.jar", "sha1": "fe75e1cad7ac1f7af4a071c4b83e39d11b65a1cc", "size": 142101, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows.jar", "sha1": "db886c1f9e313c3fa2a25543b99ccd250d3f9fb5", "size": 180026, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-arm64.jar", "sha1": "598790de603c286dbc4068b27829eacc37592786", "size": 152780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-x86.jar", "sha1": "9b07558f81a5d54dfaeb861bab3ccc86bb4477c9", "size": 148041, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2.jar", "sha1": "ae5357ed6d934546d3533993ea84c0cfb75eed95", "size": 108230, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-linux.jar", "sha1": "650981780b8bbfb3ce43657b22ec8e77bfb1f37a", "size": 579919, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos.jar", "sha1": "56fa16d15039142634e3a6d97d638b56679be821", "size": 515159, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos-arm64.jar", "sha1": "7b43d16069bdabc9ca0923ec8756a51c5d61cb75", "size": 467151, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows.jar", "sha1": "e74f299a602192faaf14b917632e4cbbb493c940", "size": 694908, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-arm64.jar", "sha1": "545ddec7959007a78b6662d616e00dacf00e1c29", "size": 627059, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-x86.jar", "sha1": "21fcb44d32ccf101017ec939fc740197677557d5", "size": 636273, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2.jar", "sha1": "ee8e95be0b438602038bc1f02dc5e3d011b1b216", "size": 928871, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-linux.jar", "sha1": "b8368430ef0d91a5acbc6fbfa47b20c3ec083aec", "size": 80464, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos.jar", "sha1": "05141389ca737369f317b1288a570534c40db9cf", "size": 41485, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos-arm64.jar", "sha1": "baadfd67936dcd7e0334dd3a9cf8dd13a0bcb009", "size": 42490, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows.jar", "sha1": "83cd34469d4e0bc335bf74c7f62206530a9480bf", "size": 101530, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-arm64.jar", "sha1": "21df035bf03dbf5001f92291b24dc951da513481", "size": 83132, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-x86.jar", "sha1": "22fa4149159154b24f6c1bd46a342d4958a9fba1", "size": 88610, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2.jar", "sha1": "a2550795014d622b686e9caac50b14baa87d2c70", "size": 118874, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-linux.jar", "sha1": "5c987f43b342d722b54970159040af76f1c87403", "size": 231821, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos.jar", "sha1": "61614cb49cee0b95587893a36bd72f63ab815c82", "size": 216457, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos-arm64.jar", "sha1": "db49f0f76e8377520b625c688cd45aacb3dcdc9b", "size": 183630, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows.jar", "sha1": "1c4f4b8353bdb78c5264ab921436f03fc9aa1ba5", "size": 261137, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-arm64.jar", "sha1": "c29df97c3cca97dc00d34e171936153764c9f78b", "size": 218460, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-x86.jar", "sha1": "a0de7bde6722fa68d25ba6afbd7395508c53c730", "size": 227583, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2.jar", "sha1": "9f65c248dd77934105274fcf8351abb75b34327c", "size": 13404, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-linux.jar", "sha1": "05d27fc67172b3fcd8400d61a7cfb75da881f609", "size": 43961, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos.jar", "sha1": "ce88dcda2fffe5d912de8ea1dabd68490a8f8471", "size": 45096, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos-arm64.jar", "sha1": "0b4aa34d244c75bcbb78d6cdb0b41200348da330", "size": 41812, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows.jar", "sha1": "54a93ed247d20007a6f579355263fdc2c030753a", "size": 130126, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-arm64.jar", "sha1": "500f5daa3b731ca282d4b90aeafda94c528d3e27", "size": 110758, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-x86.jar", "sha1": "0c1dfa1c438e0262453e7bf625289540e5cbffb2", "size": 111596, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar", "sha1": "4421d94af68e35dcaa31737a6fc59136a1e61b94", "size": 786196, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar", "sha1": "767684973f259d97e7dc66a125eb153986f177e7", "size": 114144, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos.jar", "sha1": "3256dce7fa36d6b572afa5e5730f532cf987f3bf", "size": 60240, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos-arm64.jar", "sha1": "319eea74a8829ce92fd54ce7c684b6b6557c05bb", "size": 48270, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows.jar", "sha1": "a55169ced70ffcd15f2162daf4a9c968578f6cd5", "size": 164993, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-arm64.jar", "sha1": "d900e4678449ba97ff46fa64b22e0376bf8cd00e", "size": 133200, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-x86.jar", "sha1": "ed495259b2c8f068794da0ffedfa7ae7c130b3c5", "size": 139365, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar", "sha1": "41eb7184ea9d556f23e18b5cb99cad1f8581fc00", "size": 63635, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"}}, "name": "org.slf4j:slf4j-api:2.0.7"}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", "size": 888, "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minimumLauncherVersion": 21, "releaseTime": "2023-12-07T12:56:20+00:00", "time": "2023-12-07T12:56:20+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/1.20.5.json b/src/test/resources/native-launcher/1.20.5.json new file mode 100644 index 00000000..da66645f --- /dev/null +++ b/src/test/resources/native-launcher/1.20.5.json @@ -0,0 +1 @@ +{"arguments": {"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--clientId", "${clientid}", "--xuid", "${auth_xuid}", "--userType", "${user_type}", "--versionType", "${version_type}", {"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"}, {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}, {"rules": [{"action": "allow", "features": {"has_quick_plays_support": true}}], "value": ["--quickPlayPath", "${quickPlayPath}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_singleplayer": true}}], "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_realms": true}}], "value": ["--quickPlayRealms", "${quickPlayRealms}"]}], "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, {"rules": [{"action": "allow", "os": {"name": "windows"}}], "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Djna.tmpdir=${natives_directory}", "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}", "-Dio.netty.native.workdir=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "16", "sha1": "062ddb2dd67d41cdf332bbceb37963160a296cf0", "size": 446564, "totalSize": 657414307, "url": "https://piston-meta.mojang.com/v1/packages/062ddb2dd67d41cdf332bbceb37963160a296cf0/16.json"}, "assets": "16", "complianceLevel": 1, "downloads": {"client": {"sha1": "c6b92b2374a629f20802bb284f98a4ee790e950a", "size": 26565623, "url": "https://piston-data.mojang.com/v1/objects/c6b92b2374a629f20802bb284f98a4ee790e950a/client.jar"}, "client_mappings": {"sha1": "65b1468c44fbf34382716416b5c1a16d2949217a", "size": 9421856, "url": "https://piston-data.mojang.com/v1/objects/65b1468c44fbf34382716416b5c1a16d2949217a/client.txt"}, "server": {"sha1": "79493072f65e17243fd36a699c9a96b4381feb91", "size": 51424012, "url": "https://piston-data.mojang.com/v1/objects/79493072f65e17243fd36a699c9a96b4381feb91/server.jar"}, "server_mappings": {"sha1": "14be97974a77b09eb8bca88ed9268445f0fde3e7", "size": 7283217, "url": "https://piston-data.mojang.com/v1/objects/14be97974a77b09eb8bca88ed9268445f0fde3e7/server.txt"}}, "id": "1.20.5", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}, "libraries": [{"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", "size": 1330045, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar"}}, "name": "ca.weblite:java-objc-bridge:1.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/6.4.10/oshi-core-6.4.10.jar", "sha1": "b1d8ab82d11d92fd639b56d639f8f46f739dd5fa", "size": 979212, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.4.10/oshi-core-6.4.10.jar"}}, "name": "com.github.oshi:oshi-core:6.4.10"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", "size": 283367, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar"}}, "name": "com.google.code.gson:gson:2.10.1"}, {"downloads": {"artifact": {"path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", "size": 4617, "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}, "name": "com.google.guava:failureaccess:1.0.1"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", "sha1": "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318", "size": 3041591, "url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar"}}, "name": "com.google.guava:guava:32.1.2-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/73.2/icu4j-73.2.jar", "sha1": "61ad4ef7f9131fcf6d25c34b817f90d6da06c9e9", "size": 14567819, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/73.2/icu4j-73.2.jar"}}, "name": "com.ibm.icu:icu4j:73.2"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/6.0.54/authlib-6.0.54.jar", "sha1": "de8bc95660e1b2fe8793fd427a7a10dcec5b3ea7", "size": 115242, "url": "https://libraries.minecraft.net/com/mojang/authlib/6.0.54/authlib-6.0.54.jar"}}, "name": "com.mojang:authlib:6.0.54"}, {"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar"}}, "name": "com.mojang:blocklist:1.0.10"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar", "sha1": "73e324f2ee541493a5179abf367237faa782ed21", "size": 79955, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar"}}, "name": "com.mojang:brigadier:1.2.9"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/7.0.14/datafixerupper-7.0.14.jar", "sha1": "db3485dc1e6b712bd81170d5ce6dca2fb2f8bd86", "size": 722976, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/7.0.14/datafixerupper-7.0.14.jar"}}, "name": "com.mojang:datafixerupper:7.0.14"}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.2.7/logging-1.2.7.jar", "sha1": "24cb95ffb0e3433fd6e844c04e68009e504ca1c0", "size": 15343, "url": "https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar"}}, "name": "com.mojang:logging:1.2.7"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", "size": 4439, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar"}}, "name": "com.mojang:patchy:2.2.10"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar", "sha1": "3cad216e3a7f0c19b4b394388bc9ffc446f13b14", "size": 12243, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar"}}, "name": "com.mojang:text2speech:1.17.9"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar", "sha1": "4e3eb3d79888d76b54e28b350915b5dc3919c9de", "size": 360738, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar"}}, "name": "commons-codec:commons-codec:1.16.0"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.15.1/commons-io-2.15.1.jar", "sha1": "f11560da189ab563a5c8e351941415430e9304ea", "size": 501218, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar"}}, "name": "commons-io:commons-io:2.15.1"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.2/commons-logging-1.2.jar", "sha1": "4bfc12adfe4842bf07b657f0369c4cb522955686", "size": 61829, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}, "name": "commons-logging:commons-logging:1.2"}, {"downloads": {"artifact": {"path": "io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar", "sha1": "f8f3d8644afa5e6e1a40a3a6aeb9d9aa970ecb4f", "size": 306590, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar"}}, "name": "io.netty:netty-buffer:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar", "sha1": "384ba4d75670befbedb45c4d3b497a93639c206d", "size": 345274, "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar"}}, "name": "io.netty:netty-codec:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar", "sha1": "7cceacaf11df8dc63f23d0fb58e9d4640fc88404", "size": 659930, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar", "sha1": "abb86c6906bf512bf2b797a41cd7d2e8d3cd7c36", "size": 560040, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar"}}, "name": "io.netty:netty-handler:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar", "sha1": "cec8348108dc76c47cf87c669d514be52c922144", "size": 37792, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar"}}, "name": "io.netty:netty-resolver:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar", "sha1": "795da37ded759e862457a82d9d92c4d39ce8ecee", "size": 147139, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar", "sha1": "5514744c588190ffda076b35a9b8c9f24946a960", "size": 40427, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar", "sha1": "54188f271e388e7f313aea995e82f58ce2cdb809", "size": 38954, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar", "sha1": "d469d84265ab70095b01b40886cabdd433b6e664", "size": 43897, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar", "sha1": "f37380d23c9bb079bc702910833b2fd532c9abd0", "size": 489624, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport:4.1.97.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar", "sha1": "c24946d46824bd528054bface3231d2ecb7e95e8", "size": 23326598, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.12"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.14.0/jna-platform-5.14.0.jar", "sha1": "28934d48aed814f11e4c584da55c49fa7032b31b", "size": 1369287, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.14.0/jna-platform-5.14.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.14.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar", "sha1": "67bf3eaea4f0718cb376a181a629e5f88fa1c9dd", "size": 1878533, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar"}}, "name": "net.java.dev.jna:jna:5.14.0"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", "size": 78146, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.4"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.26.0/commons-compress-1.26.0.jar", "sha1": "659feffdd12280201c8aacb8f7be94f9a883c824", "size": 1078328, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.26.0/commons-compress-1.26.0.jar"}}, "name": "org.apache.commons:commons-compress:1.26.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar", "sha1": "1ed471194b02f2c6cb734a0cd6f6f107c673afae", "size": 657952, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.14.0"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", "sha1": "e5f6cae5ca7ecaac1ec2827a9e2d65ae2869cada", "size": 780321, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.5.13"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar", "sha1": "51cf043c87253c9f58b539c9f7e44c8894223850", "size": 327891, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.4.16"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar", "sha1": "bea6fede6328fabafd7e68363161a7ea6605abd1", "size": 335001, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.22.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar", "sha1": "7183a25510a02ad00cc6a95d3b3d2a7d3c5a8dc4", "size": 1900022, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.22.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar", "sha1": "d7e6693c2606cb7e7335047d7bb96dec52db5665", "size": 27364, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1"}, {"downloads": {"artifact": {"path": "org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar", "sha1": "8872d22b293e8f5d7d56ff92be966e6dc28ebdc6", "size": 99701, "url": "https://libraries.minecraft.net/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar"}}, "name": "org.jcraft:jorbis:0.0.17"}, {"downloads": {"artifact": {"path": "org/joml/joml/1.10.5/joml-1.10.5.jar", "sha1": "22566d58af70ad3d72308bab63b8339906deb649", "size": 712082, "url": "https://libraries.minecraft.net/org/joml/joml/1.10.5/joml-1.10.5.jar"}}, "name": "org.joml:joml:1.10.5"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar", "sha1": "a0db6c84a8becc8ca05f9dbfa985edc348a824c7", "size": 450896, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar", "sha1": "149070a5480900347071b7074779531f25a6e3dc", "size": 1245129, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar", "sha1": "806d869f37ce0df388a24e17aaaf5ca0894d851b", "size": 1071983, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-patch", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar", "sha1": "b0a8c9baa9d1f54ac61e1ab9640c7659e7fa700c", "size": 1040981, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar", "sha1": "81091b006dbb43fab04c8c638e9ac87c51b4096d", "size": 1035586, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar", "sha1": "82028265a0a2ff33523ca75137ada7dc176e5210", "size": 886068, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar", "sha1": "15a8c1de7f51d07a92eae7ce1222557073a0c0c3", "size": 877480, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar", "sha1": "efa1eb78c5ccd840e9f329717109b5e892d72f8e", "size": 135546, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar", "sha1": "a03684c5e4b1b1dbbe0d29dbbdc27b985b6840f2", "size": 118478, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar", "sha1": "a1bf400f6bc64e6195596cb1430dafda46090751", "size": 140884, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar", "sha1": "ee8cc78d0a4a5b3b4600fade6d927c9fc320c858", "size": 138288, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar", "sha1": "e449e28b4891fc423c54c85fbc5bb0b9efece67a", "size": 166368, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar", "sha1": "f27018dc74f6289574502b46cce55d52817554e2", "size": 141970, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar", "sha1": "32334f3fd5270a59bad9939a93115acb6de36dcf", "size": 157123, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar", "sha1": "b543467b7ff3c6920539a88ee602d34098628be5", "size": 43896, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar", "sha1": "4f86728bf449b1dd61251c4e0ac01df1389cb51e", "size": 206779, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar", "sha1": "2906637657a57579847238c9c72d2c4bde7083f8", "size": 153131, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar", "sha1": "e9412c3ff8cb3a3bad1d3f52909ad74d8a5bdad1", "size": 141418, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar", "sha1": "426222fc027602a5f21b9c0fe79cde6a4c7a011f", "size": 180344, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar", "sha1": "ba1f3fed0ee4be0217eaa41c5bbfb4b9b1383c33", "size": 154415, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar", "sha1": "f6063b6e0f23be483c5c88d84ce51b39dc69126c", "size": 148612, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar", "sha1": "daada81ceb5fc0c291fbfdd4433cb8d9423577f2", "size": 110586, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar", "sha1": "3037360cc4595079bea240af250b6d1a527e0905", "size": 573224, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar", "sha1": "8df8338bfa77f2ebabef4e58964bd04d24805cbf", "size": 519824, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar", "sha1": "0c78b078de2fb52f45aa55d04db889a560f3544f", "size": 471012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar", "sha1": "cf83862ae95d98496b26915024c7e666d8ab1c8f", "size": 698720, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar", "sha1": "8e0615235116b9e4160dfe87bec90f5f6378bf72", "size": 630410, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar", "sha1": "87b8d5050e3adb46bb58fe1cb2669a4a48fce10d", "size": 638424, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar", "sha1": "02f6b0147078396a58979125a4c947664e98293a", "size": 929192, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar", "sha1": "62c70a4b00ca5391882b0f4b787c1588d24f1c86", "size": 80463, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar", "sha1": "1bd45997551ae8a28469f3a2b678f4b7289e12c0", "size": 41484, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar", "sha1": "d213ddef27637b1af87961ffa94d6b27036becc8", "size": 42487, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar", "sha1": "e6c1eec8be8a71951b830a4d69efc01c6531900c", "size": 101535, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar", "sha1": "65e956d3735a1abdc82eff4baec1b61174697d4b", "size": 83095, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar", "sha1": "0d32d833dcaa2f355a886eaf21f0408b5f03241d", "size": 88612, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar", "sha1": "25dd6161988d7e65f71d5065c99902402ee32746", "size": 120283, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar", "sha1": "fd1271ccd9d85eff2fa31f3fd543e02ccfaf5041", "size": 231820, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar", "sha1": "472792c98fb2c1557c060cb9da5fca6a9773621f", "size": 216456, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar", "sha1": "51c6955571fbcdb7bb538c6aa589b953b584c6af", "size": 183628, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar", "sha1": "1d9facdf6541de114b0f963be33505b7679c78cb", "size": 261297, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar", "sha1": "a584ab44de569708871f0a79561f4d8c37487f2c", "size": 219511, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar", "sha1": "b5c874687b9aac1a936501d4ed2c49567fd1b575", "size": 227800, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar", "sha1": "82d755ca94b102e9ca77283b9e2dc46d1b15fbe5", "size": 13400, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar", "sha1": "d8d58daa0c3e5fd906fee96f5fddbcbc07cc308b", "size": 44192, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar", "sha1": "6598081e346a03038a8be68eb2de614a1c2eac68", "size": 45865, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar", "sha1": "406feedb977372085a61eb0fee358183f4f4c67a", "size": 42498, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar", "sha1": "a6697981b0449a5087c1d546fc08b4f73e8f98c9", "size": 130253, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar", "sha1": "a88c494f3006eb91a7433b12a3a55a9a6c20788b", "size": 110867, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar", "sha1": "c336c84ee88cccb495c6ffa112395509e7378e8a", "size": 111797, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", "sha1": "29589b5f87ed335a6c7e7ee6a5775f81f97ecb84", "size": 785029, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar", "sha1": "1713758e3660ba66e1e954396fd18126038b33c0", "size": 114627, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", "sha1": "33a6efa288390490ce6eb6c3df47ac21ecf648cf", "size": 60543, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar", "sha1": "226246e75f6bd8d4e1895bdce8638ef87808d114", "size": 48620, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar", "sha1": "a5ed18a2b82fc91b81f40d717cb1f64c9dcb0540", "size": 165442, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar", "sha1": "e9aca8c5479b520a2a7f0d542a118140e812c5e8", "size": 133378, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar", "sha1": "9e670718e050aeaeea0c2d5b907cffb142f2e58f", "size": 139653, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar", "sha1": "4b986a99445e49ea5fbf5d149c4b63f6ed6c6780", "size": 682804, "url": "https://libraries.minecraft.net/org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar"}}, "name": "org.lz4:lz4-java:1.8.0"}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar", "sha1": "7cf2726fdcfbc8610f9a71fb3ed639871f315340", "size": 64579, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"}}, "name": "org.slf4j:slf4j-api:2.0.9"}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", "size": 888, "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minimumLauncherVersion": 21, "releaseTime": "2024-04-23T11:54:12+00:00", "time": "2024-04-23T11:54:12+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/26.3.json b/src/test/resources/native-launcher/26.3.json new file mode 100644 index 00000000..145d6535 --- /dev/null +++ b/src/test/resources/native-launcher/26.3.json @@ -0,0 +1 @@ +{"arguments": {"default-user-jvm": [{"value": ["-Xms2G", "-Xmx4G", "-XX:+UseCompactObjectHeaders", "-XX:+AlwaysPreTouch", "-XX:+UseStringDeduplication"]}, {"rules": [{"action": "allow", "os": {"name": "osx"}}, {"action": "allow", "os": {"name": "linux"}}, {"action": "allow", "os": {"name": "windows", "versionRange": {"min": "10.0.17134"}}}], "value": ["-XX:+UseZGC"]}, {"rules": [{"action": "allow", "os": {"name": "windows", "versionRange": {"max": "10.0.17134"}}}], "value": ["-XX:+UnlockExperimentalVMOptions", "-XX:+UseG1GC", "-XX:G1NewSizePercent=20", "-XX:G1ReservePercent=20", "-XX:MaxGCPauseMillis=50", "-XX:G1HeapRegionSize=32M"]}], "game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--clientId", "${clientid}", "--xuid", "${auth_xuid}", "--versionType", "${version_type}", {"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"}, {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}, {"rules": [{"action": "allow", "features": {"has_quick_plays_support": true}}], "value": ["--quickPlayPath", "${quickPlayPath}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_singleplayer": true}}], "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_realms": true}}], "value": ["--quickPlayRealms", "${quickPlayRealms}"]}], "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, {"rules": [{"action": "allow", "os": {"name": "windows"}}], "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-XX:StackShadowPages=32", "--enable-native-access=ALL-UNNAMED", "--add-exports", "java.base/jdk.internal.misc=ALL-UNNAMED", "-Djava.library.path=${natives_directory}/java", "-Djna.tmpdir=${natives_directory}/jna", "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}/lwjgl", "-Dio.netty.native.workdir=${natives_directory}/netty", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "34", "sha1": "abfaa525f923f807df8b4e4d29c1b5e3a104adbe", "size": 597035, "totalSize": 483782790, "url": "https://piston-meta.mojang.com/v1/packages/abfaa525f923f807df8b4e4d29c1b5e3a104adbe/34.json"}, "assets": "34", "complianceLevel": 1, "downloads": {"client": {"sha1": "e877b6a07acd633fb3bb475002175cec036e7b87", "size": 41483720, "url": "https://piston-data.mojang.com/v1/objects/e877b6a07acd633fb3bb475002175cec036e7b87/client.jar"}, "server": {"sha1": "33680f5f2ac32864d6d7cf5e56a705fdb3e05f4c", "size": 62294556, "url": "https://piston-data.mojang.com/v1/objects/33680f5f2ac32864d6d7cf5e56a705fdb3e05f4c/server.jar"}}, "id": "26.3", "javaVersion": {"component": "java-runtime-epsilon", "majorVersion": 25}, "libraries": [{"downloads": {"artifact": {"path": "at/yawk/lz4/lz4-java/1.10.1/lz4-java-1.10.1.jar", "sha1": "f541d7f910fe3d76f38f799c507c48cc81b12ecb", "size": 910232, "url": "https://libraries.minecraft.net/at/yawk/lz4/lz4-java/1.10.1/lz4-java-1.10.1.jar"}}, "name": "at.yawk.lz4:lz4-java:1.10.1"}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", "size": 1330045, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar"}}, "name": "ca.weblite:java-objc-bridge:1.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/azure/azure-json/1.4.0/azure-json-1.4.0.jar", "sha1": "fcc1d354dbc3e0300e5276b1bf124d0247799cd8", "size": 325945, "url": "https://libraries.minecraft.net/com/azure/azure-json/1.4.0/azure-json-1.4.0.jar"}}, "name": "com.azure:azure-json:1.4.0"}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/6.9.0/oshi-core-6.9.0.jar", "sha1": "03224870731860cfcd7744581a05b559e94291e7", "size": 1022125, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.9.0/oshi-core-6.9.0.jar"}}, "name": "com.github.oshi:oshi-core:6.9.0"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.14.0/gson-2.14.0.jar", "sha1": "efc0e34ede4e3204eaefb84a00e55e8c86634382", "size": 313604, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.14.0/gson-2.14.0.jar"}}, "name": "com.google.code.gson:gson:2.14.0"}, {"downloads": {"artifact": {"path": "com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.jar", "sha1": "aeaffd00d57023a2c947393ed251f0354f0985fc", "size": 10763, "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.jar"}}, "name": "com.google.guava:failureaccess:1.0.3"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/33.6.0-jre/guava-33.6.0-jre.jar", "sha1": "c376b13067cc99a5774403530953f7b05a91e218", "size": 3064793, "url": "https://libraries.minecraft.net/com/google/guava/guava/33.6.0-jre/guava-33.6.0-jre.jar"}}, "name": "com.google.guava:guava:33.6.0-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/78.3/icu4j-78.3.jar", "sha1": "6a851a53bb1f25689fc418dcc8935b0d93580adf", "size": 15156073, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/78.3/icu4j-78.3.jar"}}, "name": "com.ibm.icu:icu4j:78.3"}, {"downloads": {"artifact": {"path": "com/microsoft/azure/msal4j/1.24.1/msal4j-1.24.1.jar", "sha1": "c233f2d047b9755f7041adf21a858c680e4c9242", "size": 396486, "url": "https://libraries.minecraft.net/com/microsoft/azure/msal4j/1.24.1/msal4j-1.24.1.jar"}}, "name": "com.microsoft.azure:msal4j:1.24.1"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/10.0.77/authlib-10.0.77.jar", "sha1": "add8754cda96cf0cd840441632875648836f8a71", "size": 152645, "url": "https://libraries.minecraft.net/com/mojang/authlib/10.0.77/authlib-10.0.77.jar"}}, "name": "com.mojang:authlib:10.0.77"}, {"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar"}}, "name": "com.mojang:blocklist:1.0.10"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.3.11/brigadier-1.3.11.jar", "sha1": "3373d1e7bf00c8b99bed1ea4efb8c47344e4a887", "size": 80151, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.3.11/brigadier-1.3.11.jar"}}, "name": "com.mojang:brigadier:1.3.11"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/10.0.21/datafixerupper-10.0.21.jar", "sha1": "b6b2ae770c02e0c1eb90f9985b151e9085a38d0b", "size": 753612, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/10.0.21/datafixerupper-10.0.21.jar"}}, "name": "com.mojang:datafixerupper:10.0.21"}, {"downloads": {"artifact": {"path": "com/mojang/jtracy/1.14.38/jtracy-1.14.38.jar", "sha1": "cc2ad81342001b4281c305a298d7f50332354058", "size": 14292, "url": "https://libraries.minecraft.net/com/mojang/jtracy/1.14.38/jtracy-1.14.38.jar"}}, "name": "com.mojang:jtracy:1.14.38"}, {"downloads": {"artifact": {"path": "com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-linux.jar", "sha1": "d2433300f8a4e99595d513546d19ecddc3eaa15e", "size": 256636, "url": "https://libraries.minecraft.net/com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-linux.jar"}}, "name": "com.mojang:jtracy:1.14.38:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-macos.jar", "sha1": "5ef8d24352733e915e35e8c39efb099520bcabcf", "size": 228428, "url": "https://libraries.minecraft.net/com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-macos.jar"}}, "name": "com.mojang:jtracy:1.14.38:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-macos-arm64.jar", "sha1": "e5cad241e2c018c39d2b5e767e201ead40f93309", "size": 230827, "url": "https://libraries.minecraft.net/com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-macos-arm64.jar"}}, "name": "com.mojang:jtracy:1.14.38:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-windows.jar", "sha1": "3e26b44141ba853b45f734f643a6a8ac8b8fc259", "size": 49885, "url": "https://libraries.minecraft.net/com/mojang/jtracy/1.14.38/jtracy-1.14.38-natives-windows.jar"}}, "name": "com.mojang:jtracy:1.14.38:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.7.12/logging-1.7.12.jar", "sha1": "351cea64a5233361327d8d54c44277041beed97f", "size": 19540, "url": "https://libraries.minecraft.net/com/mojang/logging/1.7.12/logging-1.7.12.jar"}}, "name": "com.mojang:logging:1.7.12"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", "size": 4439, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar"}}, "name": "com.mojang:patchy:2.2.10"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.19.12/text2speech-1.19.12.jar", "sha1": "2e517a75c79d34d32de2b5d68422150836c2133e", "size": 14009, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.19.12/text2speech-1.19.12.jar"}}, "name": "com.mojang:text2speech:1.19.12"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.22.0/commons-codec-1.22.0.jar", "sha1": "6b3eb4beb7058c2a638f5f17bcb388649fd339dd", "size": 420480, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.22.0/commons-codec-1.22.0.jar"}}, "name": "commons-codec:commons-codec:1.22.0"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.20.0/commons-io-2.20.0.jar", "sha1": "36f3474daec2849c149e877614e7f979b2082cd2", "size": 563971, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.20.0/commons-io-2.20.0.jar"}}, "name": "commons-io:commons-io:2.20.0"}, {"downloads": {"artifact": {"path": "io/netty/netty-buffer/4.2.16.Final/netty-buffer-4.2.16.Final.jar", "sha1": "05397ef65dcb60670e0e0dba4854522873af3070", "size": 378032, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.2.16.Final/netty-buffer-4.2.16.Final.jar"}}, "name": "io.netty:netty-buffer:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec-base/4.2.16.Final/netty-codec-base-4.2.16.Final.jar", "sha1": "01a01d45a9efc228c6d747525848b065e0656b5f", "size": 155355, "url": "https://libraries.minecraft.net/io/netty/netty-codec-base/4.2.16.Final/netty-codec-base-4.2.16.Final.jar"}}, "name": "io.netty:netty-codec-base:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec-compression/4.2.16.Final/netty-codec-compression-4.2.16.Final.jar", "sha1": "bdbbcfcdbe217b95c14a04df4fc5d31213d5c27c", "size": 183349, "url": "https://libraries.minecraft.net/io/netty/netty-codec-compression/4.2.16.Final/netty-codec-compression-4.2.16.Final.jar"}}, "name": "io.netty:netty-codec-compression:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec-http/4.2.16.Final/netty-codec-http-4.2.16.Final.jar", "sha1": "b5e0715df90047ee5a0138c8f704f43c751009e4", "size": 678387, "url": "https://libraries.minecraft.net/io/netty/netty-codec-http/4.2.16.Final/netty-codec-http-4.2.16.Final.jar"}}, "name": "io.netty:netty-codec-http:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.2.16.Final/netty-common-4.2.16.Final.jar", "sha1": "c2bc7fa5acfa2afc777e944d84f0aa65559cc81e", "size": 819965, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.2.16.Final/netty-common-4.2.16.Final.jar"}}, "name": "io.netty:netty-common:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.2.16.Final/netty-handler-4.2.16.Final.jar", "sha1": "cc82873ae4b6d14effe480740ad838de09add70d", "size": 587814, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.2.16.Final/netty-handler-4.2.16.Final.jar"}}, "name": "io.netty:netty-handler:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.2.16.Final/netty-resolver-4.2.16.Final.jar", "sha1": "fcea6d64273b59ab9127b68c16ae98af34642139", "size": 32975, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.2.16.Final/netty-resolver-4.2.16.Final.jar"}}, "name": "io.netty:netty-resolver:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.2.16.Final/netty-transport-classes-epoll-4.2.16.Final.jar", "sha1": "cf90c7b892e284ba9f1545dce170168369b3b9d6", "size": 159935, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.2.16.Final/netty-transport-classes-epoll-4.2.16.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-kqueue/4.2.16.Final/netty-transport-classes-kqueue-4.2.16.Final.jar", "sha1": "0fa2bcf8c71626510f6fae173f78182117b70f7a", "size": 116819, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-kqueue/4.2.16.Final/netty-transport-classes-kqueue-4.2.16.Final.jar"}}, "name": "io.netty:netty-transport-classes-kqueue:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.2.16.Final/netty-transport-native-epoll-4.2.16.Final-linux-aarch_64.jar", "sha1": "eed34f3b37b4c46a0cbb49456ccc4e3bd7195e4c", "size": 43899, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.2.16.Final/netty-transport-native-epoll-4.2.16.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.2.16.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.2.16.Final/netty-transport-native-epoll-4.2.16.Final-linux-x86_64.jar", "sha1": "439c24f5c27c220deb042b42e4048d606edeb78d", "size": 42393, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.2.16.Final/netty-transport-native-epoll-4.2.16.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.2.16.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-kqueue/4.2.16.Final/netty-transport-native-kqueue-4.2.16.Final-osx-aarch_64.jar", "sha1": "ddb942cad6c0e978caa2215757512b36c5ffacec", "size": 27482, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-kqueue/4.2.16.Final/netty-transport-native-kqueue-4.2.16.Final-osx-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-kqueue/4.2.16.Final/netty-transport-native-kqueue-4.2.16.Final-osx-x86_64.jar", "sha1": "dd9d41b06355a6a1815d902ee087a693aab83755", "size": 26737, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-kqueue/4.2.16.Final/netty-transport-native-kqueue-4.2.16.Final-osx-x86_64.jar"}}, "name": "io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-x86_64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.2.16.Final/netty-transport-native-unix-common-4.2.16.Final.jar", "sha1": "3cf32390f1b80af890ac44aa0698c2ff58c2a4d7", "size": 46877, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.2.16.Final/netty-transport-native-unix-common-4.2.16.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.2.16.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.2.16.Final/netty-transport-4.2.16.Final.jar", "sha1": "ae87a3bec51d503475a19fbd718bf777f2fc06c6", "size": 549855, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.2.16.Final/netty-transport-4.2.16.Final.jar"}}, "name": "io.netty:netty-transport:4.2.16.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.18/fastutil-8.5.18.jar", "sha1": "a6cff377eecc19c2037bf31568a6d7106b50ba1f", "size": 23965563, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.18/fastutil-8.5.18.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.18"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.17.0/jna-platform-5.17.0.jar", "sha1": "a4934c44d25a9d8c2ddf4203affd20330cb3426f", "size": 1378617, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.17.0/jna-platform-5.17.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.17.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.17.0/jna-5.17.0.jar", "sha1": "33d12735bef894440780fce64f9758d420c7bae2", "size": 2002589, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.17.0/jna-5.17.0.jar"}}, "name": "net.java.dev.jna:jna:5.17.0"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", "size": 78146, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.4"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.28.0/commons-compress-1.28.0.jar", "sha1": "e482f2c7a88dac3c497e96aa420b6a769f59c8d7", "size": 1117221, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.28.0/commons-compress-1.28.0.jar"}}, "name": "org.apache.commons:commons-compress:1.28.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar", "sha1": "65897b3e5731220962e659e001904af3c3cbeba9", "size": 713862, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.20.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.26.0/log4j-api-2.26.0.jar", "sha1": "ad52af0ecf054a7e3f275a2e180ee06d9c490951", "size": 351126, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.26.0/log4j-api-2.26.0.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.26.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.26.0/log4j-core-2.26.0.jar", "sha1": "d267ca60b27ddb075e0b4e75e7875859717bf85f", "size": 2015101, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.26.0/log4j-core-2.26.0.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.26.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.26.0/log4j-slf4j2-impl-2.26.0.jar", "sha1": "668c0f0e813d83f00a7182533aa658ec7e7a0a0d", "size": 30211, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.26.0/log4j-slf4j2-impl-2.26.0.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0"}, {"downloads": {"artifact": {"path": "org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar", "sha1": "8872d22b293e8f5d7d56ff92be966e6dc28ebdc6", "size": 99701, "url": "https://libraries.minecraft.net/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar"}}, "name": "org.jcraft:jorbis:0.0.17"}, {"downloads": {"artifact": {"path": "org/joml/joml/1.10.9/joml-1.10.9.jar", "sha1": "438e036486bad66b189bff385dd07dea4f74a146", "size": 814581, "url": "https://libraries.minecraft.net/org/joml/joml/1.10.9/joml-1.10.9.jar"}}, "name": "org.joml:joml:1.10.9"}, {"downloads": {"artifact": {"path": "org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar", "sha1": "7425a601c1c7ec76645a78d22b8c6a627edee507", "size": 3819, "url": "https://libraries.minecraft.net/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar"}}, "name": "org.jspecify:jspecify:1.0.0"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3.jar", "sha1": "d2b12961d44f92860fb1ab501f9c69b7a2975236", "size": 468438, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux.jar", "sha1": "38f27c9a0fbe000bfb38b295c32944d699e246e9", "size": 1385284, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-macos.jar", "sha1": "6787804906997dcc4f22c99f0235f0f07bdd2559", "size": 1180849, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-macos-arm64.jar", "sha1": "2b6c33ec08fe2950de44bd40f6f3ff45ffa2e53e", "size": 1088629, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-windows.jar", "sha1": "0c01f319d5eeebaa143feb143cc746687c88075a", "size": 1152710, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-windows-arm64.jar", "sha1": "c611e83aea59f960d621c79c78de06745bc95a9e", "size": 1010096, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3.jar", "sha1": "350f317eaf5295b1e53f6664ef5fdeeaaa7dcbf0", "size": 48061, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux.jar", "sha1": "ca21b4b4c6d223907de645a18436ef6f677a5d4f", "size": 224040, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-macos.jar", "sha1": "ea839adbed91b7423f7e0cbe7dfaabcb922f1a64", "size": 146288, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-macos-arm64.jar", "sha1": "e3b0e4bb74f9ff9c9165638cebe8efb49af3d599", "size": 136030, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-windows.jar", "sha1": "d329f1d23bdb18e504f2f5900a93d24b2454bd05", "size": 184467, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-windows-arm64.jar", "sha1": "7362dcccaf4ee997e9af3e267d5beacb4771e93e", "size": 157515, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3.jar", "sha1": "32b52cda5af6937a4e4bd321bdb83082025cb332", "size": 154573, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux.jar", "sha1": "7daa62f9f7b38b30e8a3d3bc7069e9b2e3cd52f0", "size": 1159799, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-macos.jar", "sha1": "7aaf1d333ee024867c7a55790d568f1435d9c3f2", "size": 689297, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-macos-arm64.jar", "sha1": "12ab2b7de803ea315a56f39aebfcf148823ffe01", "size": 646627, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-windows.jar", "sha1": "097c14cc0c264d64e4200fc319d86f559f8a6931", "size": 992735, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-windows-arm64.jar", "sha1": "6198ef79d4c7a220fb65689b584e8e3a148c81b7", "size": 897409, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3.jar", "sha1": "b81769ee48050f8c71e70839505f350bf9219d90", "size": 953649, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux.jar", "sha1": "ec6fb42167943e94f7f0b14707c1737653c4044b", "size": 82244, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-macos.jar", "sha1": "fa2d57d9983c89503ee63d6d508f87c25e40a1cd", "size": 54960, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-macos-arm64.jar", "sha1": "102115f47189fbdaecce4f6e1bbff60f0435284b", "size": 43491, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-windows.jar", "sha1": "8f46f873190aa1d89f0f59818afcf125bd88adde", "size": 98282, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-windows-arm64.jar", "sha1": "f49d3b795fb4ea9b33c2b8403411eab8620879a1", "size": 84838, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3.jar", "sha1": "c02146735d6ff70d070c73532b435e5c893a346b", "size": 989769, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux.jar", "sha1": "6ed1b80b7c211a491612c70e43f27ba1f42cccd0", "size": 1665811, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-macos.jar", "sha1": "ac6ff9cd41e8c819f69ea7c9569e58cfae3a7c75", "size": 1178217, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-macos-arm64.jar", "sha1": "9165eaef243de41f544decabda5553181667a44d", "size": 983982, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-windows.jar", "sha1": "b91110fdb7a3c09feaac07ee06e0757a8ac33d03", "size": 1241055, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-windows-arm64.jar", "sha1": "7ab134eaab451560b2248b89d2836214f4ce4f6b", "size": 1102920, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3.jar", "sha1": "ba9fbc8fc4d50b3e4b436cc958ddb4abdb05867c", "size": 153441, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux.jar", "sha1": "037faa5824f75901f4961e56805aedf68e13cf8b", "size": 3872753, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-macos.jar", "sha1": "c3c180935dbc3fd2301c4195c1d0eb265537c836", "size": 2547039, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-macos-arm64.jar", "sha1": "89bf79fc7200f1fa73563c9ab25316723145b4ba", "size": 2293240, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-windows.jar", "sha1": "44266572b8b8b5bbb05e71f13471337ac0d62aba", "size": 2541979, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-windows-arm64.jar", "sha1": "bb28dabb8b7d8bea1a35de1a2be6aeade0e565d2", "size": 2359649, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3.jar", "sha1": "98e5b5ef5159100b26a169a9aed6c24e820e3eaa", "size": 150189, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux.jar", "sha1": "709eb01deec4bdafb7634b125a63fd8de172fc50", "size": 1265592, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-macos.jar", "sha1": "82fd32526a143282a6fdc8d1f51cc95ea2e23f5a", "size": 1035398, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-macos-arm64.jar", "sha1": "4b8d46f798f3d5d5185450ce1125f99eb3bf62dd", "size": 973350, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-windows.jar", "sha1": "996d1baf778567a6d8d6970a9eda5c4c18957386", "size": 966671, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-windows-arm64.jar", "sha1": "220ef902b26215f0f4718e4cdf9aa49005e33f93", "size": 950153, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3.jar", "sha1": "cdce54785bd3758e44a81f345d6260774d921c78", "size": 143041, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux.jar", "sha1": "3878e44ce27c12791e570b5c8754ef3b68401dd3", "size": 286507, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-macos.jar", "sha1": "3b4a6438e19f79f4a065f231731fcd37263e7296", "size": 221810, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-macos-arm64.jar", "sha1": "fbbcc42d384898287f92689e65c35e8969d820c0", "size": 191327, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-windows.jar", "sha1": "0ada47b3ce6917bd113759f5caad20f2cca06d4c", "size": 293076, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-windows-arm64.jar", "sha1": "094ff1f4b128a8224fbef93d4b8b9b503c65b402", "size": 241161, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3.jar", "sha1": "816a6ac87f1e447e5346ca0e7a1078353f51fbd6", "size": 110265, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux.jar", "sha1": "3a9a95d3e83f7fc49c6ed8d0e1c853b77662d90e", "size": 64034, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-macos.jar", "sha1": "cf46adfca6794642ed89658a28c13bdf58b87017", "size": 74414, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-macos-arm64.jar", "sha1": "cf3e7dec8869e955ba4d7cc47699823dd139a461", "size": 65971, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-windows.jar", "sha1": "ccdda5496cb9c1d77e7a7c94a3284ca44b5e3176", "size": 129345, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-windows-arm64.jar", "sha1": "f895ba1ad2811d1461e5b46e5057ddcbfd65841e", "size": 111851, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-vma:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar", "sha1": "ffdaf1898192059f059d66f9fa37e3debcac10e0", "size": 8866042, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl-vulkan:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3-natives-macos.jar", "sha1": "437e39098c91fe33d5e6683920fafe4a9fafd27a", "size": 1483125, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3-natives-macos-arm64.jar", "sha1": "5793efc9c640ee0a615f5e45d42eeec686e12704", "size": 1381890, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-vulkan/3.4.3/lwjgl-vulkan-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3.jar", "sha1": "7ab5265a73d8a959ed62332d546536ae6be2f0d5", "size": 1255440, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux.jar", "sha1": "2800e134537db1a175eba41c430b1018eddc48f0", "size": 137717, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-macos.jar", "sha1": "ca43c26e86ed9805f3b3e2ffdb9ccb965aabd083", "size": 84421, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-macos-arm64.jar", "sha1": "9e41227b9e0aca9510f3196d45b3c6dd4048bd78", "size": 54330, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-windows.jar", "sha1": "27e3d2f08b2bcf4fb56a58b836d9437419b980d7", "size": 178848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-windows-arm64.jar", "sha1": "cedd8fc68a4955a26e9ffdf3dc7d4d31c35e5194", "size": 152235, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.4.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar", "sha1": "d9e58ac9c7779ba3bf8142aff6c830617a7fe60f", "size": 69908, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar"}}, "name": "org.slf4j:slf4j-api:2.0.17"}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.21.2.xml", "sha1": "39384bd14c0606d812afec88d8aff595b2587dd9", "size": 1073, "url": "https://piston-data.mojang.com/v1/objects/39384bd14c0606d812afec88d8aff595b2587dd9/client-1.21.2.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minimumLauncherVersion": 21, "releaseTime": "2026-09-15T11:23:02+00:00", "time": "2026-09-15T11:23:02+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/fabric-1.20.4.json b/src/test/resources/native-launcher/fabric-1.20.4.json new file mode 100644 index 00000000..22f63e26 --- /dev/null +++ b/src/test/resources/native-launcher/fabric-1.20.4.json @@ -0,0 +1,78 @@ +{ + "id": "fabric-loader-0.15.11-1.20.4", + "inheritsFrom": "1.20.4", + "releaseTime": "2026-10-03T00:02:21+0000", + "time": "2026-10-03T00:02:21+0000", + "type": "release", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": { + "game": [], + "jvm": [ + "-DFabricMcEmu= net.minecraft.client.main.Main " + ] + }, + "libraries": [ + { + "name": "org.ow2.asm:asm:9.6", + "url": "https://maven.fabricmc.net/", + "md5": "6f8bccf756f170d4185bb24c8c2d2020", + "sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", + "sha256": "3c6fac2424db3d4a853b669f4e3d1d9c3c552235e19a319673f887083c2303a1", + "sha512": "01a5ea6f5b43bf094c52a50e18325a60af7bb02e74d24f9bc2c727d43e514578fd968b30ff22f9d2720caec071458f9ff82d11a21fbb1ebc42d8203e737c4b52", + "size": 123598 + }, + { + "name": "org.ow2.asm:asm-analysis:9.6", + "url": "https://maven.fabricmc.net/", + "md5": "31c84ef7cc893fb278952ae2d6a2674f", + "sha1": "9ce6c7b174bd997fc2552dff47964546bd7a5ec3", + "sha256": "d92832d7c37edc07c60e2559ac6118b31d642e337a6671edcb7ba9fae68edbbb", + "sha512": "e798d451addabbaeace3adf8d8e939430241d9665c81b98b0a7a705c9b91ddbb67ff86b1f7b9a7ef317f225d38da3319ed40e0fd33ddace3d2ac75c3f7b78b02", + "size": 34041 + }, + { + "name": "org.ow2.asm:asm-commons:9.6", + "url": "https://maven.fabricmc.net/", + "md5": "9e317c75534bd1da8c00a67c618ab288", + "sha1": "f1a9e5508eff490744144565c47326c8648be309", + "sha256": "7aefd0d5c0901701c69f7513feda765fb6be33af2ce7aa17c5781fc87657c511", + "sha512": "8739143062b0589e3c55d9a8fc67b716cc9b8ce8ecb6f4c1f968b587b3117c6806dae8dba92c79a52eeef05b10b25b01bb26f4e77fd20d2f52ddf2f56438901f", + "size": 72194 + }, + { + "name": "org.ow2.asm:asm-tree:9.6", + "url": "https://maven.fabricmc.net/", + "md5": "6062608f1a98afe1e853d01fa1221a9e", + "sha1": "c0cdda9d211e965d2a4448aa3fd86110f2f8c2de", + "sha256": "c43ecf17b539c777e15da7b5b86553b377e2d39a683de6285567d5283888e7ef", + "sha512": "ae0416ae62b9260da5012a3c5b6e12f34f4a0d016ec9cc9a2a1e771af12a9d63e36315c199f85d7a9d424ae39500253def0a0a581befa26d4372b21efec44781", + "size": 51935 + }, + { + "name": "org.ow2.asm:asm-util:9.6", + "url": "https://maven.fabricmc.net/", + "md5": "bd3bc1c176a787373e9a031073c9574b", + "sha1": "f77caf84eb93786a749b2baa40865b9613e3eaee", + "sha256": "c635a7402f4aa9bf66b2f4230cea62025a0fe1cd63e8729adefc9b1994fac4c3", + "sha512": "1b92e1fae7d0c082bccf751bdaeae3bf32d28b9e555c9989ad70d3e5156d19e9547232c5886a53af1973da51bf9f61266336f3bcc55ea2cf972b76370a0bd8ba", + "size": 91131 + }, + { + "name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", + "url": "https://maven.fabricmc.net/", + "md5": "9dcf01250f72212bf97f0fef55ba5acd", + "sha1": "9527e6b0d2449408958fd1302594dc65ec5ade9c", + "sha256": "6cb35f932d7455afb5b5ec21c081590b109a723e7a9cef7efe070522da7e4607", + "sha512": "cf1bc794351eca6cceee7dfb5f356664dd57ff51b80484b4a339b26805a3e95935027df3661e3b8ee7182567726f3e79531abe6a6ab86eb046f57cd71eebd5fe", + "size": 1458875 + }, + { + "name": "net.fabricmc:intermediary:1.20.4", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:fabric-loader:0.15.11", + "url": "https://maven.fabricmc.net/" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/forge-26.3-install_profile.json b/src/test/resources/native-launcher/forge-26.3-install_profile.json new file mode 100644 index 00000000..7b5f66f0 --- /dev/null +++ b/src/test/resources/native-launcher/forge-26.3-install_profile.json @@ -0,0 +1,379 @@ +{ + "_comment": [ + "Please do not automate the download and installation of Forge.", + "Our efforts are supported by ads from the download page.", + "If you MUST automate this, please consider supporting the project through https://www.patreon.com/LexManos/" + ], + "spec": 1, + "hideExtract": true, + "profile": "forge", + "version": "26.3-forge-66.0.9", + "path": "net.minecraftforge:forge:26.3-66.0.9:shim", + "minecraft": "26.3", + "serverJarPath": "{LIBRARY_DIR}/net/minecraft/server/{MINECRAFT_VERSION}/server-{MINECRAFT_VERSION}-bundled.jar", + "data": { + "MC_UNPACKED": { + "client": "[net.minecraft:client:26.3]", + "server": "[net.minecraft:server:26.3:unpacked]" + }, + "MC_UNPACKED_SHA": { + "client": "'e877b6a07acd633fb3bb475002175cec036e7b87'", + "server": "'cc3964451a3b32a0488110d5e3e6ad8d75fe6a66'" + }, + "BINPATCH": { + "client": "/data/client.lzma", + "server": "/data/server.lzma" + }, + "PATCHED": { + "client": "[net.minecraftforge:forge:26.3-66.0.9:client]", + "server": "[net.minecraftforge:forge:26.3-66.0.9:server]" + }, + "PATCHED_SHA": { + "client": "'6254fd332277905b955a8502228e23a7eae396a8'", + "server": "'de1fb52168941918686a49b5f0f4fb1ed06b80c3'" + } + }, + "processors": [ + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.6", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.6.2", + "org.ow2.asm:asm-commons:9.10.1", + "org.ow2.asm:asm-tree:9.10.1", + "org.ow2.asm:asm:9.10.1" + ], + "args": [ + "--task", + "EXTRACT_FILES", + "--archive", + "{INSTALLER}", + "--from", + "data/README.txt", + "--to", + "{ROOT}/README.txt", + "--from", + "data/run.sh", + "--to", + "{ROOT}/run.sh", + "--exec", + "{ROOT}/run.sh", + "--from", + "data/run.bat", + "--to", + "{ROOT}/run.bat", + "--from", + "data/user_jvm_args.txt", + "--to", + "{ROOT}/user_jvm_args.txt", + "--optional", + "{ROOT}/user_jvm_args.txt", + "--from", + "data/unix_args.txt", + "--to", + "{ROOT}/libraries/net/minecraftforge/forge/26.3-66.0.9/unix_args.txt", + "--from", + "data/win_args.txt", + "--to", + "{ROOT}/libraries/net/minecraftforge/forge/26.3-66.0.9/win_args.txt" + ] + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.6", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.6.2", + "org.ow2.asm:asm-commons:9.10.1", + "org.ow2.asm:asm-tree:9.10.1", + "org.ow2.asm:asm:9.10.1" + ], + "args": [ + "--task", + "BUNDLER_EXTRACT", + "--input", + "{MINECRAFT_JAR}", + "--output", + "{ROOT}/libraries/", + "--libraries" + ] + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.6", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.6.2", + "org.ow2.asm:asm-commons:9.10.1", + "org.ow2.asm:asm-tree:9.10.1", + "org.ow2.asm:asm:9.10.1" + ], + "args": [ + "--task", + "BUNDLER_EXTRACT", + "--input", + "{MINECRAFT_JAR}", + "--output", + "{MC_UNPACKED}", + "--jar-only" + ], + "outputs": { + "{MC_UNPACKED}": "{MC_UNPACKED_SHA}" + } + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:binarypatcher:1.3.4", + "classpath": [ + "net.minecraftforge:hash-utils:0.2.3", + "net.minecraftforge:srgutils:0.6.6", + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.github.jponge:lzma-java:1.3", + "com.nothome:javaxdelta:2.0.1", + "trove:trove:1.0.2" + ], + "args": [ + "--clean", + "{MC_UNPACKED}", + "--output", + "{PATCHED}", + "--apply", + "{BINPATCH}", + "--data", + "--unpatched", + "--store" + ], + "outputs": { + "{PATCHED}": "{PATCHED_SHA}" + } + }, + { + "sides": [ + "client" + ], + "jar": "net.minecraftforge:binarypatcher:1.3.4", + "classpath": [ + "net.minecraftforge:hash-utils:0.2.3", + "net.minecraftforge:srgutils:0.6.6", + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.github.jponge:lzma-java:1.3", + "com.nothome:javaxdelta:2.0.1", + "trove:trove:1.0.2" + ], + "args": [ + "--clean", + "{MINECRAFT_JAR}", + "--output", + "{PATCHED}", + "--apply", + "{BINPATCH}", + "--data", + "--unpatched", + "--store", + "--marker", + ".forge_patched_minecraft" + ], + "outputs": { + "{PATCHED}": "{PATCHED_SHA}" + } + } + ], + "libraries": [ + { + "name": "com.github.jponge:lzma-java:1.3", + "downloads": { + "artifact": { + "path": "com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar", + "url": "https://maven.minecraftforge.net/com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar", + "sha1": "a25db9d4d385ccda4825ae1b47a7a61d86e595af", + "size": 51041 + } + } + }, + { + "name": "com.google.code.gson:gson:2.10.1", + "downloads": { + "artifact": { + "path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", + "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", + "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", + "size": 283367 + } + } + }, + { + "name": "com.nothome:javaxdelta:2.0.1", + "downloads": { + "artifact": { + "path": "com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar", + "url": "https://maven.minecraftforge.net/com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar", + "sha1": "d4f5d077f1b15c6f67595015d69572303ad3f66d", + "size": 38995 + } + } + }, + { + "name": "de.siegmar:fastcsv:2.2.2", + "downloads": { + "artifact": { + "path": "de/siegmar/fastcsv/2.2.2/fastcsv-2.2.2.jar", + "url": "https://maven.minecraftforge.net/de/siegmar/fastcsv/2.2.2/fastcsv-2.2.2.jar", + "sha1": "40562843510397296de200889d9ed5f3af155e52", + "size": 32060 + } + } + }, + { + "name": "net.minecraftforge:binarypatcher:1.3.4", + "downloads": { + "artifact": { + "path": "net/minecraftforge/binarypatcher/1.3.4/binarypatcher-1.3.4.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/binarypatcher/1.3.4/binarypatcher-1.3.4.jar", + "sha1": "62739687c274efb763805eff3db9acc738ca321c", + "size": 30805 + } + } + }, + { + "name": "net.minecraftforge:forge:26.3-66.0.9:shim", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-shim.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-shim.jar", + "sha1": "c1b974630e6eb401c8397896d299834ae8f74372", + "size": 16204 + } + } + }, + { + "name": "net.minecraftforge:forge:26.3-66.0.9:universal", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-universal.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-universal.jar", + "sha1": "341ea45b6ccf5a36ea74942675cb73a6435fb80d", + "size": 2762694 + } + } + }, + { + "name": "net.minecraftforge:hash-utils:0.2.3", + "downloads": { + "artifact": { + "path": "net/minecraftforge/hash-utils/0.2.3/hash-utils-0.2.3.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/hash-utils/0.2.3/hash-utils-0.2.3.jar", + "sha1": "d929552990be336b5cd7fbb67c7b25cdf263f5d2", + "size": 22442 + } + } + }, + { + "name": "net.minecraftforge:installertools:1.4.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/installertools/1.4.6/installertools-1.4.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/installertools/1.4.6/installertools-1.4.6.jar", + "sha1": "6cbb6511802e23a045dc1d69ce1412582eb5e2d6", + "size": 93323 + } + } + }, + { + "name": "net.minecraftforge:srgutils:0.6.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/srgutils/0.6.2/srgutils-0.6.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/srgutils/0.6.2/srgutils-0.6.2.jar", + "sha1": "e8e7d58835222461405514ab69fd6b29726e950d", + "size": 74235 + } + } + }, + { + "name": "net.minecraftforge:srgutils:0.6.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/srgutils/0.6.6/srgutils-0.6.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/srgutils/0.6.6/srgutils-0.6.6.jar", + "sha1": "e17ec4f1f08313c5b3d1753a57d4bb867b24fcee", + "size": 75476 + } + } + }, + { + "name": "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "downloads": { + "artifact": { + "path": "net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar", + "url": "https://maven.minecraftforge.net/net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar", + "sha1": "02ab1a73e1e22d2b73469362a0908a98644b681d", + "size": 79368 + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "sha1": "4229e4c55fd8e01c23f9fe9884075cc628aacc50", + "size": 74840 + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "sha1": "e244332a17564c1d1572449399a842de35881be2", + "size": 51958 + } + } + }, + { + "name": "org.ow2.asm:asm:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "sha1": "ada2141c0cc52ee8f5c48cd5fa4ce0e794f22236", + "size": 126151 + } + } + }, + { + "name": "trove:trove:1.0.2", + "downloads": { + "artifact": { + "path": "trove/trove/1.0.2/trove-1.0.2.jar", + "url": "https://maven.minecraftforge.net/trove/trove/1.0.2/trove-1.0.2.jar", + "sha1": "112b40581ab9a4d3de4636d49985a7f686d181c8", + "size": 361155 + } + } + } + ], + "icon": "data:image/png;base64,AAABAAMAMDAAAAEACACoDgAANgAAACAgAAABAAgAqAgAAN4OAAAQEAAAAQAIAGgFAACGFwAAKAAAADAAAABgAAAAAQAIAAAAAAAACQAAAAAAAAAAAAAAAQAAAAAAAPX08wBMOSoAvbaxANPOywD6+fkAPioZAK+noADW0s4AnpSMAP39/ADFv7oAtKymAGpaTQDa19QA29fUAEs4KACEd20A9fT0AOPg3QCropsATz0uAPn49wCJfHMA5+XjAD4qGgDo5eMAPyoaAGVVSACMgHYA/f39AMW/uwD+/f0As6ukAEMvIADa1tIAopiQAJCFfADv7uwAt7CqALiwqgBtXlEAzsjEAPTz8gBLOCkAcmNXAOPg3gBhUEMA5uThAD0pGACdk4sAxL65AEEuHgDr6ecAQi4eAGlZTADa1tMAj4R6AO/u7QDw7u0ApZyUAG5eUgDf29kAu7SuAIN2bACqoZoAX09BANDMyADRzMgA9/f2AE88LQD49/YAwLm0AD0pGQA+KRkAnJKJAGRURwCLf3UA/Pz8AEEtHACyqqMAemxhAOvp6ADZ1dEAoZePAEUyIgC3r6kA3drXAIJ1agBxYlYAYE9CAId6cAD49/cAnJKKAJ2SigD7+/oAQS0dAOro5gDZ1dIAV0U3AH5wZQBGMiMAbV1RAMvGwQDe2tgAXEo9AEk2JgC6s60A4d7bAKmgmQD39vUA5ePhAObj4QBkU0YA1dDNAPv7+wD8+/sAsamiANjU0ACglo4AaFhMAP///gCPg3oARDEhALauqADc2dYA3dnWAPLx8ABJNicAqJ+XALuzrgCXjIMA9/b2AK2knQDUz8sAm5GJAJyRiQD6+vkAUT8wAOnn5QBALBwAQSwcANjU0QDZ1NEAVkQ2AP///wDu7OsAo5qSAMrFwADd2dcA8fDuAEg1JQC5sqwAb2BTAODd2gCpn5gA5eLgAIl9cwA/KxoA+/r6AOrn5gBVQzQAjoJ5AO3r6QC1racAa1tOANvY1QDc2NUAy8XBAM7JxACWi4IATTosAFA+LwDBu7YA+vn4AMK7tgA/KxsA6ObkAEArGwBmVkkA/v7+AFVDNQD//v4Ae25jAHxuYwDt6+oARDAhANrX0wBrW08AycS/AEc0JAC5sasA39zZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABfX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX1+MjIyMX19fX19fX19fX19fX4uMjIxfX19fX19fX19fX19fX19fX19fX19fX19frwyje3smtjUYi19fX19fi52vcCB7ewsbr19fX19fX19fX19fX19fX19fX19fX19fBT8dkJCQE0wsqjWvGq9ff0uhOIiQkHO3BV9fX19fX19fX19fX19fX19fX19fX19fBT8dkJCQQBA5jT6aUzt7QzqAEB2QkHK3BV9fX19fX19fX19fX19fX19fX19fX19fnaSikJCQN1hVkJCQkJCQkAc8ApCQkBlLr19fX19fX19fX19fX19fX19fX19fX19fX195RpCQkLBsv5CQkJCQJRMHkJCQAFqLX19fX19fX19fX19fX19fX19fX19fX19fX4tUklFekJBbm16QkJC1ipWQswAvhyFOX19fX19fX19fX19fX19fX19fX19fX19fX1+LVGhXZh2QeJCQkJCQs5CwdiiPeotfX19fX19fX19fX19fX19fX19fX19fX19fX19fX69Jq7yQkJCQkJCQkCpQGBixX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fMJyQkJCQkJCQkKe9i19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fSBaQkJCQkJCQkISxX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fTouxr69JKwqQkJCQkJCQkE+LX19fX19fX19fX19fX19fX19fX19fX19fX19fXxqLloliaEEsvnOQkJCQkJCQkIUBi19fX19fX19fX19fX19fX19fX19fX19fX1+dZFhAqL8XNJFEkJCQkJCQkJCQkIO3BV9fX19fX19fX19fX19fX19fX19fX19fi4uyaiqQkJCQkJCQkJCQkJCQkJCQkJADoK9fX19fX19fX19fX19fX19fX19fX1+vK6mfkJCQkJBbBJAJs5CQkJCQkJCQkJCQPhQaX19fX19fX19fX19fX19fX19fX68urnKQkJCQkJAtJZCKiJCQkJCQkJCQkJCQHzJZnYtfX19fX19fX19fX19fX1+LNWNrkJCQkJCQkJCTYLMpW5CQkJCQkJCQkJCQkJASJImvGotfX19fX19fX19fX69phn6QkJCQkJCQkLUGGa0LRpCQkJCQkJCQkJCQkJCQTQ4xdw+Li4uLX19fX19fi7Qnc5CQkJCQkJCQkASCsDoIFZCQs15tlVFvv3VCvKyXdBMjHDarRRQBNV9fX19fX2VdXUpKSkpKSkpKXIK7HkdMBJCQs54RlbiKbi1rPVZ8pg4OpZRnZ5mBlk5fX19fX4saGBgYGBgYGBgYGBg1D72YcY6NjVJhYSI3Nzc3ug0NDQ0NDQ0NDX0nll9fX19fX19fX19fX19fX19fX19fi4u5KwEBAQEBAQEBAQEBAQEBAQEBAQEBAQF/M19fX19fX19fX19fX19fX19fX19fX19fi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX18AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAIAAAAEAAAAABAAgAAAAAAAAEAAAAAAAAAAAAAAABAAAAAAAAzsnFAPX08wBMOSoAc2RYAGJRRACJfHIA+vn5AD4qGQDs6ugA29fUAFhHOQBZRzkAbl9TAODc2gDNyMMAlYqBALy1rwDj4N0A+Pj3AE89LgD5+PcAUD0uAD4qGgA/KhoA1tLPANfSzwCMgHYAVEI0AP79/QDa1tIAkYV8AO/u7AC4sKoAXEs9AHJjVwBhUEMA0s3KAFA9LwCvpp8A1dHNAPz8+wDEvrkAQi4eANrW0wBYRjgAycO/AH5xZgBHMyQAlImAAPj39gDAubQA5uTiAD0pGQDn5OIAPikZAGRURwBlVEcA1tHOAPz8/ADFvroAemxhAOvp6ABWRTYARTIiALevqQB/cWcAbF1QAN3a1wDz8vEASjcoALu0rwCYjYQAh3pwAJ2SigBSQDEAw724AEEtHQDZ1dIAV0U3AEYyIwCkm5MAy8bBAN7a2ADh3tsA9vb1APf29QB0ZloA5uPhAEAsGwD8+/sA6+jnAMfBvABFMSEA7uzqAGtcTwDd2dYAWkk7APLx8ABJNicAcGFVAL63sQBOOy0AraSdAPr6+QBRPzAAQCwcAEEsHADY1NEA////AMfBvQB8b2QA3dnXAEg1JQCCdGoA4N3aAPb19AC+t7IAdGVZADwnFwCakIcAY1JFAPr6+gCwqKEA19PPAMbAuwBEMCAA29jVANzY1QDKxcEAWkg6AIBzaADy8O8A4N3bAJaLggBLOSkAvbawAKyjnAD5+fgAPysbAOjm5ABAKxsA19PQANjT0AD+/v4AVUM1AEQwIQCShn0AubGrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMKj8/XGlqTExMTExpkT8/KkxMTExMTExMTExMTExMTIo4O4CTeC8WNjYHkYGIUUsEikxMTExMTExMTExMTExMFgMUbFoeh0kFSEdGME1sVQwWTExMTExMTExMTExMTEyMgX9seSZbbGyPbBhQRGyOPlhMTExMTExMTExMTExMTEyKVg1nWVMBbGwGhBJzfmOKTExMTExMTExMTExMTExMTExMkEEJbI9sbGw6QEJKTExMTExMTExMTExMTExMTExMTExYdnEobGxsbENKNGlMTExMTExMTExMTExMTExMTExpjIp2gihsbGxsKX1MTExMTExMTExMTExMTExMTGkXT2gsIXUnbGxsbGw5AmlMTExMTExMTExMTExMTEwXZRptcosIVGxsbGxsbDEuNkxMTExMTExMTExMTExpTCIAKGxsZ48cbGxsbGxsbF83B0xMTExMTExMTExMikV3H2xsbI8RiT2PbGxsbGxsbG88XBZpTExMTExMTGlgdDpsbGxsBi1hJGxsbGxsbGyPbIMgVoYWB4pMTExYLBCLNTMzMzUJZhFkbGw6c11XQycOfDJ6km5uI2lMTExFCk5OTk5OThsLXoUNUl8JHWuOjXt7GY1NK18PikxMTGmMjIyMjIyMaYwWcBUTExMVFRUVFRUVFRUVJWJMTExMTExMTExMTExMTExqaWlpaWlpaWlpaWlpaWlpaUxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAADUz8sA5+TiAPr5+QDDvLcAVEIzAEczJADy8e8A0s3JAEMvHwDl4uAAVkQ2APj39wDQy8cAmY6FAEEtHQBUQjQA9vX1AFA+LwBDLyAAl4yDAD8rGwBAKxsA3drXAN7a1wCZjoYAQS0eAO3r6QCkm5MATDorAE06KwCTiH8AXEs9AP7+/gCnnZYA6eflAFZFNgD4+PcAoZePAOvp6ABKNicAfW9kAOfl4wDj4d4Av7mzAJ+VjQBINCUAQzAgAHdpXQBALBsAV0U3APn4+ACLfnQAnpOLAEYyIwBCLh4AraSdAFVDNQCIfHIAQCwcAHhpXgBzZVkAU0EzAN7b2ADOyMQAPioaAD8qGgBOOywAXUw+AP///wDb19QATDkqAFtKPADGwLsAbl9TAPn5+ADZ1dIASjcoAJGFfACNgXcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODjoODg4ODjoODg4ODg4ODi4cBUBAQDVGEg4ODg4ODhRDBxs7SS80DEcUDg4ODg46QgMJASQiPisdOg4ODg4ODhQnTTJEGihMOg4ODg4OFQ4RMU4CRBYdFA4ODg4OQSMhFykQREQLOTAVDg4ONjxLREoBBkREICYTOAgZDkYzJSwNHkgqRQA/SDcYHxQOFEBAQDpCBD0EBA8KIy0ODg4ODg4OOjo6Ojo6OjoODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "json": "/version.json", + "logo": "/big_logo.png", + "mirrorList": "https://files.minecraftforge.net/mirrors-2.0.json", + "welcome": "Welcome to the Forge installer." +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/forge-26.3-version.json b/src/test/resources/native-launcher/forge-26.3-version.json new file mode 100644 index 00000000..4e0bf1f0 --- /dev/null +++ b/src/test/resources/native-launcher/forge-26.3-version.json @@ -0,0 +1,467 @@ +{ + "_comment": [ + "Please do not automate the download and installation of Forge.", + "Our efforts are supported by ads from the download page.", + "If you MUST automate this, please consider supporting the project through https://www.patreon.com/LexManos/" + ], + "id": "26.3-forge-66.0.9", + "time": "2026-09-30T21:13:14Z", + "releaseTime": "2026-09-30T21:13:14Z", + "inheritsFrom": "26.3", + "type": "release", + "logging": {}, + "mainClass": "net.minecraftforge.bootstrap.ForgeBootstrap", + "libraries": [ + { + "name": "net.minecraftforge:forge:26.3-66.0.9:universal", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-universal.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-universal.jar", + "sha1": "341ea45b6ccf5a36ea74942675cb73a6435fb80d", + "size": 2762694 + } + } + }, + { + "name": "net.minecraftforge:forge:26.3-66.0.9:client", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-client.jar", + "url": "", + "sha1": "6254fd332277905b955a8502228e23a7eae396a8", + "size": 82957715 + } + } + }, + { + "name": "net.minecraftforge:JarJarFileSystems:0.4.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarFileSystems/0.4.2/JarJarFileSystems-0.4.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarFileSystems/0.4.2/JarJarFileSystems-0.4.2.jar", + "sha1": "b47957ee0163282fc1116ab71e56c3d5e305d786", + "size": 33497 + } + } + }, + { + "name": "net.minecraftforge:roimfs:1.0.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/roimfs/1.0.0/roimfs-1.0.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/roimfs/1.0.0/roimfs-1.0.0.jar", + "sha1": "8a9f5b427f8e419d635ba1e347e323b355a43007", + "size": 30240 + } + } + }, + { + "name": "com.google.guava:guava:33.6.0-jre", + "downloads": { + "artifact": { + "path": "com/google/guava/guava/33.6.0-jre/guava-33.6.0-jre.jar", + "url": "https://libraries.minecraft.net/com/google/guava/guava/33.6.0-jre/guava-33.6.0-jre.jar", + "sha1": "c376b13067cc99a5774403530953f7b05a91e218", + "size": 3064793 + } + } + }, + { + "name": "com.google.guava:failureaccess:1.0.3", + "downloads": { + "artifact": { + "path": "com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.jar", + "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.3/failureaccess-1.0.3.jar", + "sha1": "aeaffd00d57023a2c947393ed251f0354f0985fc", + "size": 10763 + } + } + }, + { + "name": "net.minecraftforge:securemodules:2.2.24", + "downloads": { + "artifact": { + "path": "net/minecraftforge/securemodules/2.2.24/securemodules-2.2.24.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/securemodules/2.2.24/securemodules-2.2.24.jar", + "sha1": "d8402fa81935b1a6440240f98079b14d0462ccac", + "size": 101247 + } + } + }, + { + "name": "net.minecraftforge:unsafe:0.9.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/unsafe/0.9.2/unsafe-0.9.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/unsafe/0.9.2/unsafe-0.9.2.jar", + "sha1": "33bad02861cabd77c54a8c9d1205e8638bbbbb4f", + "size": 7768 + } + } + }, + { + "name": "org.ow2.asm:asm:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "sha1": "ada2141c0cc52ee8f5c48cd5fa4ce0e794f22236", + "size": 126151 + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "sha1": "e244332a17564c1d1572449399a842de35881be2", + "size": 51958 + } + } + }, + { + "name": "org.ow2.asm:asm-util:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar", + "sha1": "7bb9d450e8d4cbf9f9e04096c44bbfe7fba80b15", + "size": 95628 + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "sha1": "4229e4c55fd8e01c23f9fe9884075cc628aacc50", + "size": 74840 + } + } + }, + { + "name": "org.ow2.asm:asm-analysis:9.10.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar", + "sha1": "8d49f14d51f632cb1d87c88d1ceaf50db0d8af1b", + "size": 35140 + } + } + }, + { + "name": "net.minecraftforge:bootstrap:2.1.8", + "downloads": { + "artifact": { + "path": "net/minecraftforge/bootstrap/2.1.8/bootstrap-2.1.8.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/bootstrap/2.1.8/bootstrap-2.1.8.jar", + "sha1": "7ce55508387cf35eeb0eb4895bd84bec854ead93", + "size": 11124 + } + } + }, + { + "name": "net.minecraftforge:bootstrap-api:2.1.8", + "downloads": { + "artifact": { + "path": "net/minecraftforge/bootstrap-api/2.1.8/bootstrap-api-2.1.8.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/bootstrap-api/2.1.8/bootstrap-api-2.1.8.jar", + "sha1": "f77d6527282dfc12e2460a3155467f40f9a2673b", + "size": 2059 + } + } + }, + { + "name": "net.minecraftforge:accesstransformers:8.2.17", + "downloads": { + "artifact": { + "path": "net/minecraftforge/accesstransformers/8.2.17/accesstransformers-8.2.17.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/accesstransformers/8.2.17/accesstransformers-8.2.17.jar", + "sha1": "8834af62843c7c8fb17d00208a3831e9efb3e6b1", + "size": 41635 + } + } + }, + { + "name": "net.minecraftforge:eventbus:7.0.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/eventbus/7.0.6/eventbus-7.0.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/eventbus/7.0.6/eventbus-7.0.6.jar", + "sha1": "e48b41bf58cf9120b76701430ba10255949915f8", + "size": 55369 + } + } + }, + { + "name": "org.jspecify:jspecify:1.0.1", + "downloads": { + "artifact": { + "path": "org/jspecify/jspecify/1.0.1/jspecify-1.0.1.jar", + "url": "https://maven.minecraftforge.net/org/jspecify/jspecify/1.0.1/jspecify-1.0.1.jar", + "sha1": "3d60fd98eb8ade73004f4195c37b6317e02cf3d7", + "size": 3064 + } + } + }, + { + "name": "net.minecraftforge:forgespi:8.0.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forgespi/8.0.0/forgespi-8.0.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forgespi/8.0.0/forgespi-8.0.0.jar", + "sha1": "60512b0b4cbc4c3ddd82c89f89226527d21904b2", + "size": 30082 + } + } + }, + { + "name": "net.minecraftforge:coremods-api:5.3.1", + "downloads": { + "artifact": { + "path": "net/minecraftforge/coremods-api/5.3.1/coremods-api-5.3.1.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/coremods-api/5.3.1/coremods-api-5.3.1.jar", + "sha1": "86c7b8efae5d43de06bc7d4da4292c1ee0f543bf", + "size": 15300 + } + } + }, + { + "name": "net.minecraftforge:modlauncher:10.2.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/modlauncher/10.2.6/modlauncher-10.2.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/modlauncher/10.2.6/modlauncher-10.2.6.jar", + "sha1": "9594303353e913090ee1100152fd14d9ef62c5ef", + "size": 136147 + } + } + }, + { + "name": "net.minecraftforge:mergetool-api:1.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/mergetool-api/1.0/mergetool-api-1.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/mergetool-api/1.0/mergetool-api-1.0.jar", + "sha1": "589fac8080d2eb657bb37f464151abcd25524d64", + "size": 2624 + } + } + }, + { + "name": "com.electronwill.night-config:toml:3.7.4", + "downloads": { + "artifact": { + "path": "com/electronwill/night-config/toml/3.7.4/toml-3.7.4.jar", + "url": "https://maven.minecraftforge.net/com/electronwill/night-config/toml/3.7.4/toml-3.7.4.jar", + "sha1": "aae582c90434a801bc1c34806631446f78ef1797", + "size": 39542 + } + } + }, + { + "name": "com.electronwill.night-config:core:3.7.4", + "downloads": { + "artifact": { + "path": "com/electronwill/night-config/core/3.7.4/core-3.7.4.jar", + "url": "https://maven.minecraftforge.net/com/electronwill/night-config/core/3.7.4/core-3.7.4.jar", + "sha1": "fcd552161d0a6c2f0fcf75e337abbb040c597e1d", + "size": 360099 + } + } + }, + { + "name": "org.apache.maven:maven-artifact:3.8.8", + "downloads": { + "artifact": { + "path": "org/apache/maven/maven-artifact/3.8.8/maven-artifact-3.8.8.jar", + "url": "https://maven.minecraftforge.net/org/apache/maven/maven-artifact/3.8.8/maven-artifact-3.8.8.jar", + "sha1": "c4324db6f73f14e1327da2a1acec293d75212ae7", + "size": 58097 + } + } + }, + { + "name": "net.minecrell:terminalconsoleappender:1.2.0", + "downloads": { + "artifact": { + "path": "net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar", + "sha1": "96d02cd3b384ff015a8fef4223bcb4ccf1717c95", + "size": 15977 + } + } + }, + { + "name": "org.jline:jline-reader:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-reader/3.25.1/jline-reader-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-reader/3.25.1/jline-reader-3.25.1.jar", + "sha1": "46d87459a98d74be8c77645f2bc3c65bb2fc4f3a", + "size": 183668 + } + } + }, + { + "name": "org.jline:jline-terminal:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-terminal/3.25.1/jline-terminal-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-terminal/3.25.1/jline-terminal-3.25.1.jar", + "sha1": "44e0e53397c39d01c525bad3735097596a7d889a", + "size": 261895 + } + } + }, + { + "name": "org.jline:jline-terminal-jna:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-terminal-jna/3.25.1/jline-terminal-jna-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-terminal-jna/3.25.1/jline-terminal-jna-3.25.1.jar", + "sha1": "7e39efe62b2710b9f2bbefd2911e176b2efe1105", + "size": 73759 + } + } + }, + { + "name": "org.spongepowered:mixin:0.8.7", + "downloads": { + "artifact": { + "path": "org/spongepowered/mixin/0.8.7/mixin-0.8.7.jar", + "url": "https://maven.minecraftforge.net/org/spongepowered/mixin/0.8.7/mixin-0.8.7.jar", + "sha1": "8ab114ac385e6dbdad5efafe28aba4df8120915f", + "size": 1134454 + } + } + }, + { + "name": "io.github.llamalad7:mixinextras-forge:0.5.4", + "downloads": { + "artifact": { + "path": "io/github/llamalad7/mixinextras-forge/0.5.4/mixinextras-forge-0.5.4.jar", + "url": "https://maven.minecraftforge.net/io/github/llamalad7/mixinextras-forge/0.5.4/mixinextras-forge-0.5.4.jar", + "sha1": "ff3cadb413a78c2b4a32a02dd1a9260f2e0b6238", + "size": 634046 + } + } + }, + { + "name": "net.minecraftforge:JarJarSelector:0.4.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarSelector/0.4.2/JarJarSelector-0.4.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarSelector/0.4.2/JarJarSelector-0.4.2.jar", + "sha1": "01d78218008febe9715df57e1e337736dbd4e602", + "size": 16658 + } + } + }, + { + "name": "net.minecraftforge:JarJarMetadata:0.4.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarMetadata/0.4.2/JarJarMetadata-0.4.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarMetadata/0.4.2/JarJarMetadata-0.4.2.jar", + "sha1": "bfc05a595d113838628a0ddc90139b4edc9445c1", + "size": 15990 + } + } + }, + { + "name": "net.minecraftforge:fmlcore:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlcore/26.3-66.0.9/fmlcore-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlcore/26.3-66.0.9/fmlcore-26.3-66.0.9.jar", + "sha1": "c5a6761de5faa431de27f6ede979286c4a78d4a2", + "size": 121352 + } + } + }, + { + "name": "net.minecraftforge:fmlloader:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlloader/26.3-66.0.9/fmlloader-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlloader/26.3-66.0.9/fmlloader-26.3-66.0.9.jar", + "sha1": "bf0ef24dedc635fc4a97f23c7c4e6df30e0c9e5c", + "size": 258986 + } + } + }, + { + "name": "net.minecraftforge:fmlearlydisplay:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlearlydisplay/26.3-66.0.9/fmlearlydisplay-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlearlydisplay/26.3-66.0.9/fmlearlydisplay-26.3-66.0.9.jar", + "sha1": "71501bc1ac07f3131794880c780bb342c2e27598", + "size": 171641 + } + } + }, + { + "name": "net.minecraftforge:javafmllanguage:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/javafmllanguage/26.3-66.0.9/javafmllanguage-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/javafmllanguage/26.3-66.0.9/javafmllanguage-26.3-66.0.9.jar", + "sha1": "850791da2b62267bac8e17e98eade65510d30e38", + "size": 26589 + } + } + }, + { + "name": "net.minecraftforge:lowcodelanguage:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/lowcodelanguage/26.3-66.0.9/lowcodelanguage-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/lowcodelanguage/26.3-66.0.9/lowcodelanguage-26.3-66.0.9.jar", + "sha1": "dad1c135abb2c9b50f4bb52ab2bb3af47e6feade", + "size": 7172 + } + } + }, + { + "name": "net.minecraftforge:mclanguage:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/mclanguage/26.3-66.0.9/mclanguage-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/mclanguage/26.3-66.0.9/mclanguage-26.3-66.0.9.jar", + "sha1": "879b9cc524f3eca793081c88324609444ebb1b7d", + "size": 4826 + } + } + }, + { + "name": "net.minecraftforge:forge-transformers:26.3-66.0.9", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge-transformers/26.3-66.0.9/forge-transformers-26.3-66.0.9.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge-transformers/26.3-66.0.9/forge-transformers-26.3-66.0.9.jar", + "sha1": "b5a9aecfe0bca5ff06aea7b85e9a151a781eb5c3", + "size": 14572 + } + } + } + ], + "arguments": { + "game": [ + "--launchTarget", + "forge_client" + ], + "jvm": [ + "-Djava.net.preferIPv6Addresses=system", + "-XX:+UseCompactObjectHeaders", + "-XX:StackShadowPages=32" + ] + } +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/forge-install_profile.json b/src/test/resources/native-launcher/forge-install_profile.json new file mode 100644 index 00000000..9e221c1c --- /dev/null +++ b/src/test/resources/native-launcher/forge-install_profile.json @@ -0,0 +1,556 @@ +{ + "_comment": [ + "Please do not automate the download and installation of Forge.", + "Our efforts are supported by ads from the download page.", + "If you MUST automate this, please consider supporting the project through https://www.patreon.com/LexManos/" + ], + "hideExtract": true, + "spec": 1, + "profile": "forge", + "version": "1.20.4-forge-49.2.0", + "path": "net.minecraftforge:forge:1.20.4-49.2.0:shim", + "minecraft": "1.20.4", + "serverJarPath": "{LIBRARY_DIR}/net/minecraft/server/{MINECRAFT_VERSION}/server-{MINECRAFT_VERSION}-bundled.jar", + "data": { + "MAPPINGS": { + "client": "[de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700:mappings@tsrg]", + "server": "[de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700:mappings@tsrg]" + }, + "MAPPINGS_SHA": { + "client": "'a9852bd9d5b3cd76530c1b32c9d12d3937f8c980'", + "server": "'a9852bd9d5b3cd76530c1b32c9d12d3937f8c980'" + }, + "MOJMAPS": { + "client": "[net.minecraft:client:1.20.4-20231207.112700:mappings@tsrg]", + "server": "[net.minecraft:server:1.20.4-20231207.112700:mappings@tsrg]" + }, + "MOJMAPS_SHA": { + "client": "'e05e02372cad860b65b1f77c045cc8acac2a5bdf'", + "server": "'cb3fdd70bfe2fe32d0c871af14393978dea134d0'" + }, + "MERGED_MAPPINGS": { + "client": "[de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700:mappings-merged@tsrg]", + "server": "[de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700:mappings-merged@tsrg]" + }, + "MERGED_MAPPINGS_SHA": { + "client": "'4cf91698cd6c2e08dbee0160518d0284c7bf9a46'", + "server": "'bf428f38e22faccb073c1be152fd89e1700d2419'" + }, + "MC_UNPACKED": { + "client": "[net.minecraft:client:1.20.4]", + "server": "[net.minecraft:server:1.20.4:unpacked]" + }, + "MC_UNPACKED_SHA": { + "client": "'fd19469fed4a4b4c15b2d5133985f0e3e7816a8a'", + "server": "'4451682405756cc760b389e9ab6223ae01b39f47'" + }, + "MC_SRG": { + "client": "[net.minecraft:client:1.20.4-20231207.112700:srg]", + "server": "[net.minecraft:server:1.20.4-20231207.112700:srg]" + }, + "MC_SRG_SHA": { + "client": "'f8ebc98b830ae8456b3cb10e07123d7fbffa6484'", + "server": "'314930ff99ca66bd8499d39c3b59aaa48ebe8c78'" + }, + "BINPATCH": { + "client": "/data/client.lzma", + "server": "/data/server.lzma" + }, + "PATCHED": { + "client": "[net.minecraftforge:forge:1.20.4-49.2.0:client]", + "server": "[net.minecraftforge:forge:1.20.4-49.2.0:server]" + }, + "PATCHED_SHA": { + "client": "'4cfcb4bdbed70a0a1c44c1c0a7a40d8eca0177e2'", + "server": "'d4b009a3364668667e1c507ffa74c81406ca72b2'" + } + }, + "processors": [ + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "EXTRACT_FILES", + "--archive", + "{INSTALLER}", + "--from", + "data/README.txt", + "--to", + "{ROOT}/README.txt", + "--from", + "data/run.sh", + "--to", + "{ROOT}/run.sh", + "--exec", + "{ROOT}/run.sh", + "--from", + "data/run.bat", + "--to", + "{ROOT}/run.bat", + "--from", + "data/user_jvm_args.txt", + "--to", + "{ROOT}/user_jvm_args.txt", + "--optional", + "{ROOT}/user_jvm_args.txt", + "--from", + "data/unix_args.txt", + "--to", + "{ROOT}/libraries/net/minecraftforge/forge/1.20.4-49.2.0/unix_args.txt", + "--from", + "data/win_args.txt", + "--to", + "{ROOT}/libraries/net/minecraftforge/forge/1.20.4-49.2.0/win_args.txt" + ] + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "BUNDLER_EXTRACT", + "--input", + "{MINECRAFT_JAR}", + "--output", + "{ROOT}/libraries/", + "--libraries" + ] + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "BUNDLER_EXTRACT", + "--input", + "{MINECRAFT_JAR}", + "--output", + "{MC_UNPACKED}", + "--jar-only" + ], + "outputs": { + "{MC_UNPACKED}": "{MC_UNPACKED_SHA}" + } + }, + { + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "MCP_DATA", + "--input", + "[de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700@zip]", + "--output", + "{MAPPINGS}", + "--key", + "mappings" + ], + "outputs": { + "{MAPPINGS}": "{MAPPINGS_SHA}" + } + }, + { + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "DOWNLOAD_MOJMAPS", + "--sanitize", + "--version", + "1.20.4", + "--side", + "{SIDE}", + "--output", + "{MOJMAPS}" + ], + "outputs": { + "{MOJMAPS}": "{MOJMAPS_SHA}" + } + }, + { + "jar": "net.minecraftforge:installertools:1.4.0", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.google.code.gson:gson:2.10.1", + "de.siegmar:fastcsv:2.2.2", + "net.minecraftforge:srgutils:0.5.6", + "org.ow2.asm:asm-commons:9.6", + "org.ow2.asm:asm-tree:9.6", + "org.ow2.asm:asm:9.6" + ], + "args": [ + "--task", + "MERGE_MAPPING", + "--left", + "{MAPPINGS}", + "--right", + "{MOJMAPS}", + "--output", + "{MERGED_MAPPINGS}", + "--classes", + "--reverse-right" + ], + "outputs": { + "{MERGED_MAPPINGS}": "{MERGED_MAPPINGS_SHA}" + } + }, + { + "sides": [ + "server" + ], + "jar": "net.minecraftforge:ForgeAutoRenamingTool:1.0.6", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "org.ow2.asm:asm-commons:9.5", + "org.ow2.asm:asm-tree:9.5", + "org.ow2.asm:asm:9.5", + "net.minecraftforge:srgutils:0.5.1" + ], + "args": [ + "--input", + "{MC_UNPACKED}", + "--output", + "{MC_SRG}", + "--names", + "{MERGED_MAPPINGS}", + "--ann-fix", + "--ids-fix", + "--src-fix", + "--record-fix", + "--strip-sigs" + ], + "outputs": { + "{MC_SRG}": "{MC_SRG_SHA}" + } + }, + { + "sides": [ + "client" + ], + "jar": "net.minecraftforge:ForgeAutoRenamingTool:1.0.6", + "classpath": [ + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "org.ow2.asm:asm-commons:9.5", + "org.ow2.asm:asm-tree:9.5", + "org.ow2.asm:asm:9.5", + "net.minecraftforge:srgutils:0.5.1" + ], + "args": [ + "--input", + "{MINECRAFT_JAR}", + "--output", + "{MC_SRG}", + "--names", + "{MERGED_MAPPINGS}", + "--ann-fix", + "--ids-fix", + "--src-fix", + "--record-fix", + "--strip-sigs" + ], + "outputs": { + "{MC_SRG}": "{MC_SRG_SHA}" + } + }, + { + "jar": "net.minecraftforge:binarypatcher:1.2.0", + "classpath": [ + "net.minecraftforge:srgutils:0.5.6", + "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "com.github.jponge:lzma-java:1.3", + "com.nothome:javaxdelta:2.0.1", + "trove:trove:1.0.2" + ], + "args": [ + "--clean", + "{MC_SRG}", + "--output", + "{PATCHED}", + "--apply", + "{BINPATCH}", + "--data", + "--unpatched" + ], + "outputs": { + "{PATCHED}": "{PATCHED_SHA}" + } + } + ], + "libraries": [ + { + "name": "com.github.jponge:lzma-java:1.3", + "downloads": { + "artifact": { + "path": "com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar", + "url": "https://maven.minecraftforge.net/com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar", + "sha1": "a25db9d4d385ccda4825ae1b47a7a61d86e595af", + "size": 51041 + } + } + }, + { + "name": "com.google.code.gson:gson:2.10.1", + "downloads": { + "artifact": { + "path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", + "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", + "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", + "size": 283367 + } + } + }, + { + "name": "com.nothome:javaxdelta:2.0.1", + "downloads": { + "artifact": { + "path": "com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar", + "url": "https://maven.minecraftforge.net/com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar", + "sha1": "d4f5d077f1b15c6f67595015d69572303ad3f66d", + "size": 38995 + } + } + }, + { + "name": "de.oceanlabs.mcp:mcp_config:1.20.4-20231207.112700@zip", + "downloads": { + "artifact": { + "path": "de/oceanlabs/mcp/mcp_config/1.20.4-20231207.112700/mcp_config-1.20.4-20231207.112700.zip", + "url": "https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp_config/1.20.4-20231207.112700/mcp_config-1.20.4-20231207.112700.zip", + "sha1": "55d51be7dba2ea898d2213e4312f29f76a8788c6", + "size": 1872980 + } + } + }, + { + "name": "de.siegmar:fastcsv:2.2.2", + "downloads": { + "artifact": { + "path": "de/siegmar/fastcsv/2.2.2/fastcsv-2.2.2.jar", + "url": "https://maven.minecraftforge.net/de/siegmar/fastcsv/2.2.2/fastcsv-2.2.2.jar", + "sha1": "40562843510397296de200889d9ed5f3af155e52", + "size": 32060 + } + } + }, + { + "name": "net.minecraftforge:ForgeAutoRenamingTool:1.0.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/ForgeAutoRenamingTool/1.0.6/ForgeAutoRenamingTool-1.0.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/ForgeAutoRenamingTool/1.0.6/ForgeAutoRenamingTool-1.0.6.jar", + "sha1": "f0cb8d14326a36d7b2b6654d53974844619c8bec", + "size": 95836 + } + } + }, + { + "name": "net.minecraftforge:binarypatcher:1.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/binarypatcher/1.2.0/binarypatcher-1.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/binarypatcher/1.2.0/binarypatcher-1.2.0.jar", + "sha1": "e5fea8a667a3ebff4e94fb74467c99c02914e4bc", + "size": 27514 + } + } + }, + { + "name": "net.minecraftforge:forge:1.20.4-49.2.0:shim", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-shim.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-shim.jar", + "sha1": "9f2a1a7379976e35eb685266c243e6d5a1bc0235", + "size": 10602 + } + } + }, + { + "name": "net.minecraftforge:forge:1.20.4-49.2.0:universal", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-universal.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-universal.jar", + "sha1": "2864252fc0b89d86cd0d7a298d5ec10720957ae8", + "size": 2487660 + } + } + }, + { + "name": "net.minecraftforge:installertools:1.4.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/installertools/1.4.0/installertools-1.4.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/installertools/1.4.0/installertools-1.4.0.jar", + "sha1": "4447724cfe7cdef8e6e9fa0dea7da8d94aa52734", + "size": 87392 + } + } + }, + { + "name": "net.minecraftforge:srgutils:0.5.1", + "downloads": { + "artifact": { + "path": "net/minecraftforge/srgutils/0.5.1/srgutils-0.5.1.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/srgutils/0.5.1/srgutils-0.5.1.jar", + "sha1": "2268068226f4fc241be0b4592758dc0fc8371837", + "size": 69815 + } + } + }, + { + "name": "net.minecraftforge:srgutils:0.5.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/srgutils/0.5.6/srgutils-0.5.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/srgutils/0.5.6/srgutils-0.5.6.jar", + "sha1": "4844e46a377eebd8bb4da594e6b487126790b18e", + "size": 72082 + } + } + }, + { + "name": "net.sf.jopt-simple:jopt-simple:6.0-alpha-3", + "downloads": { + "artifact": { + "path": "net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar", + "url": "https://maven.minecraftforge.net/net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar", + "sha1": "02ab1a73e1e22d2b73469362a0908a98644b681d", + "size": 79368 + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.5", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-commons/9.5/asm-commons-9.5.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-commons/9.5/asm-commons-9.5.jar", + "sha1": "19ab5b5800a3910d30d3a3e64fdb00fd0cb42de0", + "size": 72209 + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.6", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", + "sha1": "f1a9e5508eff490744144565c47326c8648be309", + "size": 72194 + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.5", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-tree/9.5/asm-tree-9.5.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-tree/9.5/asm-tree-9.5.jar", + "sha1": "fd33c8b6373abaa675be407082fdfda35021254a", + "size": 51944 + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.6", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", + "sha1": "c0cdda9d211e965d2a4448aa3fd86110f2f8c2de", + "size": 51935 + } + } + }, + { + "name": "org.ow2.asm:asm:9.5", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm/9.5/asm-9.5.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm/9.5/asm-9.5.jar", + "sha1": "dc6ea1875f4d64fbc85e1691c95b96a3d8569c90", + "size": 121863 + } + } + }, + { + "name": "org.ow2.asm:asm:9.6", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm/9.6/asm-9.6.jar", + "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar", + "sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", + "size": 123598 + } + } + }, + { + "name": "trove:trove:1.0.2", + "downloads": { + "artifact": { + "path": "trove/trove/1.0.2/trove-1.0.2.jar", + "url": "https://maven.minecraftforge.net/trove/trove/1.0.2/trove-1.0.2.jar", + "sha1": "112b40581ab9a4d3de4636d49985a7f686d181c8", + "size": 361155 + } + } + } + ], + "icon": "data:image/png;base64,AAABAAMAMDAAAAEACACoDgAANgAAACAgAAABAAgAqAgAAN4OAAAQEAAAAQAIAGgFAACGFwAAKAAAADAAAABgAAAAAQAIAAAAAAAACQAAAAAAAAAAAAAAAQAAAAAAAPX08wBMOSoAvbaxANPOywD6+fkAPioZAK+noADW0s4AnpSMAP39/ADFv7oAtKymAGpaTQDa19QA29fUAEs4KACEd20A9fT0AOPg3QCropsATz0uAPn49wCJfHMA5+XjAD4qGgDo5eMAPyoaAGVVSACMgHYA/f39AMW/uwD+/f0As6ukAEMvIADa1tIAopiQAJCFfADv7uwAt7CqALiwqgBtXlEAzsjEAPTz8gBLOCkAcmNXAOPg3gBhUEMA5uThAD0pGACdk4sAxL65AEEuHgDr6ecAQi4eAGlZTADa1tMAj4R6AO/u7QDw7u0ApZyUAG5eUgDf29kAu7SuAIN2bACqoZoAX09BANDMyADRzMgA9/f2AE88LQD49/YAwLm0AD0pGQA+KRkAnJKJAGRURwCLf3UA/Pz8AEEtHACyqqMAemxhAOvp6ADZ1dEAoZePAEUyIgC3r6kA3drXAIJ1agBxYlYAYE9CAId6cAD49/cAnJKKAJ2SigD7+/oAQS0dAOro5gDZ1dIAV0U3AH5wZQBGMiMAbV1RAMvGwQDe2tgAXEo9AEk2JgC6s60A4d7bAKmgmQD39vUA5ePhAObj4QBkU0YA1dDNAPv7+wD8+/sAsamiANjU0ACglo4AaFhMAP///gCPg3oARDEhALauqADc2dYA3dnWAPLx8ABJNicAqJ+XALuzrgCXjIMA9/b2AK2knQDUz8sAm5GJAJyRiQD6+vkAUT8wAOnn5QBALBwAQSwcANjU0QDZ1NEAVkQ2AP///wDu7OsAo5qSAMrFwADd2dcA8fDuAEg1JQC5sqwAb2BTAODd2gCpn5gA5eLgAIl9cwA/KxoA+/r6AOrn5gBVQzQAjoJ5AO3r6QC1racAa1tOANvY1QDc2NUAy8XBAM7JxACWi4IATTosAFA+LwDBu7YA+vn4AMK7tgA/KxsA6ObkAEArGwBmVkkA/v7+AFVDNQD//v4Ae25jAHxuYwDt6+oARDAhANrX0wBrW08AycS/AEc0JAC5sasA39zZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABfX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX1+MjIyMX19fX19fX19fX19fX4uMjIxfX19fX19fX19fX19fX19fX19fX19fX19frwyje3smtjUYi19fX19fi52vcCB7ewsbr19fX19fX19fX19fX19fX19fX19fX19fBT8dkJCQE0wsqjWvGq9ff0uhOIiQkHO3BV9fX19fX19fX19fX19fX19fX19fX19fBT8dkJCQQBA5jT6aUzt7QzqAEB2QkHK3BV9fX19fX19fX19fX19fX19fX19fX19fnaSikJCQN1hVkJCQkJCQkAc8ApCQkBlLr19fX19fX19fX19fX19fX19fX19fX19fX195RpCQkLBsv5CQkJCQJRMHkJCQAFqLX19fX19fX19fX19fX19fX19fX19fX19fX4tUklFekJBbm16QkJC1ipWQswAvhyFOX19fX19fX19fX19fX19fX19fX19fX19fX1+LVGhXZh2QeJCQkJCQs5CwdiiPeotfX19fX19fX19fX19fX19fX19fX19fX19fX19fX69Jq7yQkJCQkJCQkCpQGBixX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fMJyQkJCQkJCQkKe9i19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fSBaQkJCQkJCQkISxX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fTouxr69JKwqQkJCQkJCQkE+LX19fX19fX19fX19fX19fX19fX19fX19fX19fXxqLloliaEEsvnOQkJCQkJCQkIUBi19fX19fX19fX19fX19fX19fX19fX19fX1+dZFhAqL8XNJFEkJCQkJCQkJCQkIO3BV9fX19fX19fX19fX19fX19fX19fX19fi4uyaiqQkJCQkJCQkJCQkJCQkJCQkJADoK9fX19fX19fX19fX19fX19fX19fX1+vK6mfkJCQkJBbBJAJs5CQkJCQkJCQkJCQPhQaX19fX19fX19fX19fX19fX19fX68urnKQkJCQkJAtJZCKiJCQkJCQkJCQkJCQHzJZnYtfX19fX19fX19fX19fX1+LNWNrkJCQkJCQkJCTYLMpW5CQkJCQkJCQkJCQkJASJImvGotfX19fX19fX19fX69phn6QkJCQkJCQkLUGGa0LRpCQkJCQkJCQkJCQkJCQTQ4xdw+Li4uLX19fX19fi7Qnc5CQkJCQkJCQkASCsDoIFZCQs15tlVFvv3VCvKyXdBMjHDarRRQBNV9fX19fX2VdXUpKSkpKSkpKXIK7HkdMBJCQs54RlbiKbi1rPVZ8pg4OpZRnZ5mBlk5fX19fX4saGBgYGBgYGBgYGBg1D72YcY6NjVJhYSI3Nzc3ug0NDQ0NDQ0NDX0nll9fX19fX19fX19fX19fX19fX19fi4u5KwEBAQEBAQEBAQEBAQEBAQEBAQEBAQF/M19fX19fX19fX19fX19fX19fX19fX19fi4uLi4uLi4uLi4uLi4uLi4uLi4uLi4uLX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX18AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAIAAAAEAAAAABAAgAAAAAAAAEAAAAAAAAAAAAAAABAAAAAAAAzsnFAPX08wBMOSoAc2RYAGJRRACJfHIA+vn5AD4qGQDs6ugA29fUAFhHOQBZRzkAbl9TAODc2gDNyMMAlYqBALy1rwDj4N0A+Pj3AE89LgD5+PcAUD0uAD4qGgA/KhoA1tLPANfSzwCMgHYAVEI0AP79/QDa1tIAkYV8AO/u7AC4sKoAXEs9AHJjVwBhUEMA0s3KAFA9LwCvpp8A1dHNAPz8+wDEvrkAQi4eANrW0wBYRjgAycO/AH5xZgBHMyQAlImAAPj39gDAubQA5uTiAD0pGQDn5OIAPikZAGRURwBlVEcA1tHOAPz8/ADFvroAemxhAOvp6ABWRTYARTIiALevqQB/cWcAbF1QAN3a1wDz8vEASjcoALu0rwCYjYQAh3pwAJ2SigBSQDEAw724AEEtHQDZ1dIAV0U3AEYyIwCkm5MAy8bBAN7a2ADh3tsA9vb1APf29QB0ZloA5uPhAEAsGwD8+/sA6+jnAMfBvABFMSEA7uzqAGtcTwDd2dYAWkk7APLx8ABJNicAcGFVAL63sQBOOy0AraSdAPr6+QBRPzAAQCwcAEEsHADY1NEA////AMfBvQB8b2QA3dnXAEg1JQCCdGoA4N3aAPb19AC+t7IAdGVZADwnFwCakIcAY1JFAPr6+gCwqKEA19PPAMbAuwBEMCAA29jVANzY1QDKxcEAWkg6AIBzaADy8O8A4N3bAJaLggBLOSkAvbawAKyjnAD5+fgAPysbAOjm5ABAKxsA19PQANjT0AD+/v4AVUM1AEQwIQCShn0AubGrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMKj8/XGlqTExMTExpkT8/KkxMTExMTExMTExMTExMTIo4O4CTeC8WNjYHkYGIUUsEikxMTExMTExMTExMTExMFgMUbFoeh0kFSEdGME1sVQwWTExMTExMTExMTExMTEyMgX9seSZbbGyPbBhQRGyOPlhMTExMTExMTExMTExMTEyKVg1nWVMBbGwGhBJzfmOKTExMTExMTExMTExMTExMTExMkEEJbI9sbGw6QEJKTExMTExMTExMTExMTExMTExMTExYdnEobGxsbENKNGlMTExMTExMTExMTExMTExMTExpjIp2gihsbGxsKX1MTExMTExMTExMTExMTExMTGkXT2gsIXUnbGxsbGw5AmlMTExMTExMTExMTExMTEwXZRptcosIVGxsbGxsbDEuNkxMTExMTExMTExMTExpTCIAKGxsZ48cbGxsbGxsbF83B0xMTExMTExMTExMikV3H2xsbI8RiT2PbGxsbGxsbG88XBZpTExMTExMTGlgdDpsbGxsBi1hJGxsbGxsbGyPbIMgVoYWB4pMTExYLBCLNTMzMzUJZhFkbGw6c11XQycOfDJ6km5uI2lMTExFCk5OTk5OThsLXoUNUl8JHWuOjXt7GY1NK18PikxMTGmMjIyMjIyMaYwWcBUTExMVFRUVFRUVFRUVJWJMTExMTExMTExMTExMTExqaWlpaWlpaWlpaWlpaWlpaUxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAADUz8sA5+TiAPr5+QDDvLcAVEIzAEczJADy8e8A0s3JAEMvHwDl4uAAVkQ2APj39wDQy8cAmY6FAEEtHQBUQjQA9vX1AFA+LwBDLyAAl4yDAD8rGwBAKxsA3drXAN7a1wCZjoYAQS0eAO3r6QCkm5MATDorAE06KwCTiH8AXEs9AP7+/gCnnZYA6eflAFZFNgD4+PcAoZePAOvp6ABKNicAfW9kAOfl4wDj4d4Av7mzAJ+VjQBINCUAQzAgAHdpXQBALBsAV0U3APn4+ACLfnQAnpOLAEYyIwBCLh4AraSdAFVDNQCIfHIAQCwcAHhpXgBzZVkAU0EzAN7b2ADOyMQAPioaAD8qGgBOOywAXUw+AP///wDb19QATDkqAFtKPADGwLsAbl9TAPn5+ADZ1dIASjcoAJGFfACNgXcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODjoODg4ODjoODg4ODg4ODi4cBUBAQDVGEg4ODg4ODhRDBxs7SS80DEcUDg4ODg46QgMJASQiPisdOg4ODg4ODhQnTTJEGihMOg4ODg4OFQ4RMU4CRBYdFA4ODg4OQSMhFykQREQLOTAVDg4ONjxLREoBBkREICYTOAgZDkYzJSwNHkgqRQA/SDcYHxQOFEBAQDpCBD0EBA8KIy0ODg4ODg4OOjo6Ojo6OjoODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "json": "/version.json", + "logo": "/big_logo.png", + "mirrorList": "https://files.minecraftforge.net/mirrors-2.0.json", + "welcome": "Welcome to the Forge installer." +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/forge-version.json b/src/test/resources/native-launcher/forge-version.json new file mode 100644 index 00000000..cc13ec35 --- /dev/null +++ b/src/test/resources/native-launcher/forge-version.json @@ -0,0 +1,456 @@ +{ + "_comment": [ + "Please do not automate the download and installation of Forge.", + "Our efforts are supported by ads from the download page.", + "If you MUST automate this, please consider supporting the project through https://www.patreon.com/LexManos/" + ], + "id": "1.20.4-forge-49.2.0", + "time": "2025-03-05T00:14:00+00:00", + "releaseTime": "2025-03-05T00:14:00+00:00", + "inheritsFrom": "1.20.4", + "type": "release", + "logging": { + + }, + "mainClass": "net.minecraftforge.bootstrap.ForgeBootstrap", + "libraries": [ + { + "name": "net.minecraftforge:forge:1.20.4-49.2.0:universal", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-universal.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-universal.jar", + "sha1": "2864252fc0b89d86cd0d7a298d5ec10720957ae8", + "size": 2487660 + } + } + }, + { + "name": "net.minecraftforge:forge:1.20.4-49.2.0:client", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-client.jar", + "url": "", + "sha1": "4cfcb4bdbed70a0a1c44c1c0a7a40d8eca0177e2", + "size": 26348916 + } + } + }, + { + "name": "net.minecraftforge:JarJarFileSystems:0.3.26", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarFileSystems/0.3.26/JarJarFileSystems-0.3.26.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarFileSystems/0.3.26/JarJarFileSystems-0.3.26.jar", + "sha1": "4b48cd32749b87a18afee96f33b825305feea289", + "size": 33588 + } + } + }, + { + "name": "com.google.guava:guava:32.1.2-jre", + "downloads": { + "artifact": { + "path": "com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", + "url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", + "sha1": "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318", + "size": 3041591 + } + } + }, + { + "name": "com.google.guava:failureaccess:1.0.1", + "downloads": { + "artifact": { + "path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", + "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", + "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", + "size": 4617 + } + } + }, + { + "name": "net.minecraftforge:securemodules:2.2.21", + "downloads": { + "artifact": { + "path": "net/minecraftforge/securemodules/2.2.21/securemodules-2.2.21.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/securemodules/2.2.21/securemodules-2.2.21.jar", + "sha1": "a036e07b6064da635df3a3480591884fca3714d4", + "size": 99261 + } + } + }, + { + "name": "net.minecraftforge:unsafe:0.9.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/unsafe/0.9.2/unsafe-0.9.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/unsafe/0.9.2/unsafe-0.9.2.jar", + "sha1": "33bad02861cabd77c54a8c9d1205e8638bbbbb4f", + "size": 7768 + } + } + }, + { + "name": "org.ow2.asm:asm:9.7.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm/9.7.1/asm-9.7.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar", + "sha1": "f0ed132a49244b042cd0e15702ab9f2ce3cc8436", + "size": 126093 + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.7.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-tree/9.7.1/asm-tree-9.7.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-tree/9.7.1/asm-tree-9.7.1.jar", + "sha1": "3a53139787663b139de76b627fca0084ab60d32c", + "size": 51939 + } + } + }, + { + "name": "org.ow2.asm:asm-util:9.7.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-util/9.7.1/asm-util-9.7.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-util/9.7.1/asm-util-9.7.1.jar", + "sha1": "9e23359b598ec6b74b23e53110dd5c577adf2243", + "size": 94519 + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.7.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-commons/9.7.1/asm-commons-9.7.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-commons/9.7.1/asm-commons-9.7.1.jar", + "sha1": "406c6a2225cfe1819f102a161e54cc16a5c24f75", + "size": 73459 + } + } + }, + { + "name": "org.ow2.asm:asm-analysis:9.7.1", + "downloads": { + "artifact": { + "path": "org/ow2/asm/asm-analysis/9.7.1/asm-analysis-9.7.1.jar", + "url": "https://maven.minecraftforge.net/org/ow2/asm/asm-analysis/9.7.1/asm-analysis-9.7.1.jar", + "sha1": "f97a3b319f0ed6a8cd944dc79060d3912a28985f", + "size": 35126 + } + } + }, + { + "name": "net.minecraftforge:bootstrap:2.1.8", + "downloads": { + "artifact": { + "path": "net/minecraftforge/bootstrap/2.1.8/bootstrap-2.1.8.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/bootstrap/2.1.8/bootstrap-2.1.8.jar", + "sha1": "7ce55508387cf35eeb0eb4895bd84bec854ead93", + "size": 11124 + } + } + }, + { + "name": "net.minecraftforge:bootstrap-api:2.1.8", + "downloads": { + "artifact": { + "path": "net/minecraftforge/bootstrap-api/2.1.8/bootstrap-api-2.1.8.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/bootstrap-api/2.1.8/bootstrap-api-2.1.8.jar", + "sha1": "f77d6527282dfc12e2460a3155467f40f9a2673b", + "size": 2059 + } + } + }, + { + "name": "net.minecraftforge:accesstransformers:8.1.1", + "downloads": { + "artifact": { + "path": "net/minecraftforge/accesstransformers/8.1.1/accesstransformers-8.1.1.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/accesstransformers/8.1.1/accesstransformers-8.1.1.jar", + "sha1": "55b3a0604fec2b22c183a3e49b6f3da4e7aaaf27", + "size": 76622 + } + } + }, + { + "name": "org.antlr:antlr4-runtime:4.9.1", + "downloads": { + "artifact": { + "path": "org/antlr/antlr4-runtime/4.9.1/antlr4-runtime-4.9.1.jar", + "url": "https://maven.minecraftforge.net/org/antlr/antlr4-runtime/4.9.1/antlr4-runtime-4.9.1.jar", + "sha1": "428664f05d2b7f7b7610204b5aa7c1763f62011a", + "size": 337868 + } + } + }, + { + "name": "net.minecraftforge:eventbus:6.2.27", + "downloads": { + "artifact": { + "path": "net/minecraftforge/eventbus/6.2.27/eventbus-6.2.27.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/eventbus/6.2.27/eventbus-6.2.27.jar", + "sha1": "1a7914b85a47ef9e8f333b694a6f8c0dc6a57db7", + "size": 58819 + } + } + }, + { + "name": "net.jodah:typetools:0.6.3", + "downloads": { + "artifact": { + "path": "net/jodah/typetools/0.6.3/typetools-0.6.3.jar", + "url": "https://maven.minecraftforge.net/net/jodah/typetools/0.6.3/typetools-0.6.3.jar", + "sha1": "a01aaa6ddaea9ec07ec4f209487b7a46a526283a", + "size": 18281 + } + } + }, + { + "name": "net.minecraftforge:forgespi:7.1.3", + "downloads": { + "artifact": { + "path": "net/minecraftforge/forgespi/7.1.3/forgespi-7.1.3.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/forgespi/7.1.3/forgespi-7.1.3.jar", + "sha1": "a70210a2f27f3a8fa83b6c1d1abf7acfd4ec05da", + "size": 30933 + } + } + }, + { + "name": "net.minecraftforge:coremods:5.2.6", + "downloads": { + "artifact": { + "path": "net/minecraftforge/coremods/5.2.6/coremods-5.2.6.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/coremods/5.2.6/coremods-5.2.6.jar", + "sha1": "9528a9f2133ec5284122ac3d62cfff8bc8c6bba0", + "size": 31941 + } + } + }, + { + "name": "org.openjdk.nashorn:nashorn-core:15.4", + "downloads": { + "artifact": { + "path": "org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar", + "url": "https://maven.minecraftforge.net/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar", + "sha1": "f67f5ffaa5f5130cf6fb9b133da00c7df3b532a5", + "size": 2167292 + } + } + }, + { + "name": "net.minecraftforge:modlauncher:10.1.2", + "downloads": { + "artifact": { + "path": "net/minecraftforge/modlauncher/10.1.2/modlauncher-10.1.2.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/modlauncher/10.1.2/modlauncher-10.1.2.jar", + "sha1": "58d8bf1f2b5f892bdcbc852098eccb687dcb55fb", + "size": 124508 + } + } + }, + { + "name": "net.minecraftforge:mergetool-api:1.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/mergetool-api/1.0/mergetool-api-1.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/mergetool-api/1.0/mergetool-api-1.0.jar", + "sha1": "589fac8080d2eb657bb37f464151abcd25524d64", + "size": 2624 + } + } + }, + { + "name": "com.electronwill.night-config:toml:3.6.7", + "downloads": { + "artifact": { + "path": "com/electronwill/night-config/toml/3.6.7/toml-3.6.7.jar", + "url": "https://maven.minecraftforge.net/com/electronwill/night-config/toml/3.6.7/toml-3.6.7.jar", + "sha1": "ae2870aef9c31e5e392ffd662ea2d8a4e98a31bd", + "size": 33875 + } + } + }, + { + "name": "com.electronwill.night-config:core:3.6.7", + "downloads": { + "artifact": { + "path": "com/electronwill/night-config/core/3.6.7/core-3.6.7.jar", + "url": "https://maven.minecraftforge.net/com/electronwill/night-config/core/3.6.7/core-3.6.7.jar", + "sha1": "39bbc8694e2f1dc5eab92f01bc3248606ecd0ebd", + "size": 206812 + } + } + }, + { + "name": "org.apache.maven:maven-artifact:3.8.8", + "downloads": { + "artifact": { + "path": "org/apache/maven/maven-artifact/3.8.8/maven-artifact-3.8.8.jar", + "url": "https://maven.minecraftforge.net/org/apache/maven/maven-artifact/3.8.8/maven-artifact-3.8.8.jar", + "sha1": "c4324db6f73f14e1327da2a1acec293d75212ae7", + "size": 58097 + } + } + }, + { + "name": "net.minecrell:terminalconsoleappender:1.2.0", + "downloads": { + "artifact": { + "path": "net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar", + "sha1": "96d02cd3b384ff015a8fef4223bcb4ccf1717c95", + "size": 15977 + } + } + }, + { + "name": "org.jline:jline-reader:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-reader/3.25.1/jline-reader-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-reader/3.25.1/jline-reader-3.25.1.jar", + "sha1": "46d87459a98d74be8c77645f2bc3c65bb2fc4f3a", + "size": 183668 + } + } + }, + { + "name": "org.jline:jline-terminal:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-terminal/3.25.1/jline-terminal-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-terminal/3.25.1/jline-terminal-3.25.1.jar", + "sha1": "44e0e53397c39d01c525bad3735097596a7d889a", + "size": 261895 + } + } + }, + { + "name": "org.jline:jline-terminal-jna:3.25.1", + "downloads": { + "artifact": { + "path": "org/jline/jline-terminal-jna/3.25.1/jline-terminal-jna-3.25.1.jar", + "url": "https://maven.minecraftforge.net/org/jline/jline-terminal-jna/3.25.1/jline-terminal-jna-3.25.1.jar", + "sha1": "7e39efe62b2710b9f2bbefd2911e176b2efe1105", + "size": 73759 + } + } + }, + { + "name": "org.spongepowered:mixin:0.8.5", + "downloads": { + "artifact": { + "path": "org/spongepowered/mixin/0.8.5/mixin-0.8.5.jar", + "url": "https://maven.minecraftforge.net/org/spongepowered/mixin/0.8.5/mixin-0.8.5.jar", + "sha1": "9d1c0c3a304ae6697ecd477218fa61b850bf57fc", + "size": 1089277 + } + } + }, + { + "name": "net.minecraftforge:JarJarSelector:0.3.26", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarSelector/0.3.26/JarJarSelector-0.3.26.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarSelector/0.3.26/JarJarSelector-0.3.26.jar", + "sha1": "1e032096a4d07e5bbb7b2f3b2beee8d5e02879e9", + "size": 17346 + } + } + }, + { + "name": "net.minecraftforge:JarJarMetadata:0.3.26", + "downloads": { + "artifact": { + "path": "net/minecraftforge/JarJarMetadata/0.3.26/JarJarMetadata-0.3.26.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/JarJarMetadata/0.3.26/JarJarMetadata-0.3.26.jar", + "sha1": "6db967d6cce100e4abdeed0abfd594b9f42e0d10", + "size": 15863 + } + } + }, + { + "name": "net.minecraftforge:fmlcore:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlcore/1.20.4-49.2.0/fmlcore-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlcore/1.20.4-49.2.0/fmlcore-1.20.4-49.2.0.jar", + "sha1": "a3f0b582ea3603564b73e0c83c98a877eb562936", + "size": 118539 + } + } + }, + { + "name": "net.minecraftforge:fmlloader:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlloader/1.20.4-49.2.0/fmlloader-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlloader/1.20.4-49.2.0/fmlloader-1.20.4-49.2.0.jar", + "sha1": "67d8962f2233c0952d97f77209933ea5a4d405d4", + "size": 241540 + } + } + }, + { + "name": "net.minecraftforge:fmlearlydisplay:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/fmlearlydisplay/1.20.4-49.2.0/fmlearlydisplay-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/fmlearlydisplay/1.20.4-49.2.0/fmlearlydisplay-1.20.4-49.2.0.jar", + "sha1": "d236d0544ae8053d3ac3be07f3bf0d65c7018891", + "size": 170444 + } + } + }, + { + "name": "net.minecraftforge:javafmllanguage:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/javafmllanguage/1.20.4-49.2.0/javafmllanguage-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/javafmllanguage/1.20.4-49.2.0/javafmllanguage-1.20.4-49.2.0.jar", + "sha1": "4cb601d0a8894de9c15eab632f5fe3e3b9cb4523", + "size": 17028 + } + } + }, + { + "name": "net.minecraftforge:lowcodelanguage:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/lowcodelanguage/1.20.4-49.2.0/lowcodelanguage-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/lowcodelanguage/1.20.4-49.2.0/lowcodelanguage-1.20.4-49.2.0.jar", + "sha1": "adabdb6a285ba61bcacb3b52d50a85863aa33917", + "size": 7327 + } + } + }, + { + "name": "net.minecraftforge:mclanguage:1.20.4-49.2.0", + "downloads": { + "artifact": { + "path": "net/minecraftforge/mclanguage/1.20.4-49.2.0/mclanguage-1.20.4-49.2.0.jar", + "url": "https://maven.minecraftforge.net/net/minecraftforge/mclanguage/1.20.4-49.2.0/mclanguage-1.20.4-49.2.0.jar", + "sha1": "2fae82893317b483d9a54b3f5a4e31479afd8e8c", + "size": 4924 + } + } + } + ], + "arguments": { + "game": [ + "--launchTarget", + "forge_client" + ], + "jvm": [ + "-Djava.net.preferIPv6Addresses=system" + ] + } +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/neoforge-26.3-install_profile.json b/src/test/resources/native-launcher/neoforge-26.3-install_profile.json new file mode 100644 index 00000000..8ffe0c3c --- /dev/null +++ b/src/test/resources/native-launcher/neoforge-26.3-install_profile.json @@ -0,0 +1,408 @@ +{ + "spec": 1, + "profile": "NeoForge", + "version": "neoforge-26.3.0.46-beta", + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAACXBIWXMAAAsSAAALEgHS3X78AAAAPFBMVEWLjpQAAAD59PTm8vHV4N/n2dPH0dG9x8fHo7mqsLOgpanmjDfXdC+NcWi/YTR1dYCkTjdmU00mKjMTFRpppmRQAAAAAnRSTlP/AOW3MEoAAAE3SURBVHja7dvLboMwAERRcHjEqWtM+P9/7WLIYiyKoFXTVrlniZDnbmDnpv1lTfupFwtono4AAgiohjtzOaD7pkYIIICAh4tEGWVwsxlklNmMm/xwAgggoHE+ObpZ3sUDVv7KbgcBBBCwHdCJd8x+bC+zFDNLL9ZRh0QhgAAC/lrAuKU6/SpFsilylapx3EIAAQScCcgmyZtJks12QBQCCCDgSIBPujrAeQ4BBBBwPqAaDtKLByTTS5AqhAACCNgOmGT3PxDkLmnHXYLk1WCiTEIAAQRUAVGKVF/3IkmySbJIL2lVJAoBBBCwH2CKhZxXfJkAAgj4ekA2QRYTJBsCCCDg/wc8FBOkN0GKiSsCCCDgSIA/jXuKOT9JAAEE7AbcJP6ImxBAAAFVQCvT07VCAAEEcPWbgA9tTDwHvNgQFgAAAABJRU5ErkJggg==", + "minecraft": "26.3", + "json": "/version.json", + "logo": "/big_logo.png", + "welcome": "Welcome to the simple NeoForge installer", + "mirrorList": "https://mirrors.neoforged.net", + "hideExtract": true, + "data": { + "BINPATCH": { + "client": "/data/client.lzma", + "server": "/data/client.lzma" + }, + "PATCHED": { + "client": "[net.neoforged:minecraft-client-patched:26.3.0.46-beta]", + "server": "[net.neoforged:minecraft-server-patched:26.3.0.46-beta]" + }, + "MCP_VERSION": { + "client": "'26.3-1'", + "server": "'26.3-1'" + } + }, + "processors": [ + { + "sides": [ + "server" + ], + "jar": "net.neoforged.installertools:installertools:4.0.17:fatjar", + "classpath": [ + "net.neoforged.installertools:installertools:4.0.17:fatjar" + ], + "args": [ + "--task", + "EXTRACT_FILES", + "--archive", + "{INSTALLER}", + "--from", + "data/run.sh", + "--to", + "{ROOT}/run.sh", + "--exec", + "{ROOT}/run.sh", + "--from", + "data/run.bat", + "--to", + "{ROOT}/run.bat", + "--from", + "data/user_jvm_args.txt", + "--to", + "{ROOT}/user_jvm_args.txt", + "--optional", + "{ROOT}/user_jvm_args.txt", + "--from", + "data/win_args.txt", + "--to", + "{ROOT}/libraries/net/neoforged/neoforge/26.3.0.46-beta/win_args.txt", + "--from", + "data/unix_args.txt", + "--to", + "{ROOT}/libraries/net/neoforged/neoforge/26.3.0.46-beta/unix_args.txt" + ] + }, + { + "jar": "net.neoforged.installertools:installertools:4.0.17:fatjar", + "classpath": [ + "net.neoforged.installertools:installertools:4.0.17:fatjar" + ], + "args": [ + "--task", + "PROCESS_MINECRAFT_JAR", + "--no-mod-manifest", + "--input", + "{MINECRAFT_JAR}", + "--output", + "{PATCHED}", + "--extract-libraries-to", + "{ROOT}/libraries/", + "--apply-patches", + "{BINPATCH}" + ] + } + ], + "libraries": [ + { + "name": "net.neoforged.fancymodloader:earlydisplay:12.0.8", + "downloads": { + "artifact": { + "sha1": "efc16b2939a51b13aaeb500eff4caa82f2ac9d08", + "size": 394059, + "url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/12.0.8/earlydisplay-12.0.8.jar", + "path": "net/neoforged/fancymodloader/earlydisplay/12.0.8/earlydisplay-12.0.8.jar" + } + } + }, + { + "name": "net.neoforged.fancymodloader:loader:12.0.8", + "downloads": { + "artifact": { + "sha1": "176019d74cae7a468f2069ffc3c924f6a6f70a63", + "size": 673587, + "url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/12.0.8/loader-12.0.8.jar", + "path": "net/neoforged/fancymodloader/loader/12.0.8/loader-12.0.8.jar" + } + } + }, + { + "name": "net.neoforged:accesstransformers:11.0.2", + "downloads": { + "artifact": { + "sha1": "25dcb69c78b16669935f0ab91dff830ce192379d", + "size": 20292, + "url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/11.0.2/accesstransformers-11.0.2.jar", + "path": "net/neoforged/accesstransformers/11.0.2/accesstransformers-11.0.2.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.10.1", + "downloads": { + "artifact": { + "sha1": "4229e4c55fd8e01c23f9fe9884075cc628aacc50", + "size": 74840, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "path": "org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar" + } + } + }, + { + "name": "net.neoforged:mergetool:2.0.7:api", + "downloads": { + "artifact": { + "sha1": "793b18456e92c4a7f66d9b5d8bf7a9c1c7a20b20", + "size": 2550, + "url": "https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.7/mergetool-2.0.7-api.jar", + "path": "net/neoforged/mergetool/2.0.7/mergetool-2.0.7-api.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-util:9.10.1", + "downloads": { + "artifact": { + "sha1": "7bb9d450e8d4cbf9f9e04096c44bbfe7fba80b15", + "size": 95628, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar", + "path": "org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-analysis:9.10.1", + "downloads": { + "artifact": { + "sha1": "8d49f14d51f632cb1d87c88d1ceaf50db0d8af1b", + "size": 35140, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar", + "path": "org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.10.1", + "downloads": { + "artifact": { + "sha1": "e244332a17564c1d1572449399a842de35881be2", + "size": 51958, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "path": "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar" + } + } + }, + { + "name": "net.neoforged:bus:8.0.5", + "downloads": { + "artifact": { + "sha1": "5b2d33285ab5d1554e9798ad98c40d6ea3868bd5", + "size": 30340, + "url": "https://maven.neoforged.net/releases/net/neoforged/bus/8.0.5/bus-8.0.5.jar", + "path": "net/neoforged/bus/8.0.5/bus-8.0.5.jar" + } + } + }, + { + "name": "org.ow2.asm:asm:9.10.1", + "downloads": { + "artifact": { + "sha1": "ada2141c0cc52ee8f5c48cd5fa4ce0e794f22236", + "size": 126151, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "path": "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar" + } + } + }, + { + "name": "com.electronwill.night-config:toml:3.9.0", + "downloads": { + "artifact": { + "sha1": "8548891033db17e8f219b453cac38fb865ee4224", + "size": 43254, + "url": "https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.9.0/toml-3.9.0.jar", + "path": "com/electronwill/night-config/toml/3.9.0/toml-3.9.0.jar" + } + } + }, + { + "name": "com.electronwill.night-config:core:3.9.0", + "downloads": { + "artifact": { + "sha1": "29bba7ced5c9768adda06384c61fff2c33dc5e05", + "size": 381299, + "url": "https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.9.0/core-3.9.0.jar", + "path": "com/electronwill/night-config/core/3.9.0/core-3.9.0.jar" + } + } + }, + { + "name": "net.neoforged:JarJarSelector:0.5.1", + "downloads": { + "artifact": { + "sha1": "fb620ee1e164b0d4e527759df69317237c51133d", + "size": 17083, + "url": "https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.5.1/JarJarSelector-0.5.1.jar", + "path": "net/neoforged/JarJarSelector/0.5.1/JarJarSelector-0.5.1.jar" + } + } + }, + { + "name": "net.neoforged:JarJarMetadata:0.5.1", + "downloads": { + "artifact": { + "sha1": "09344da1c9bab76064f484a0d5921a1ca290a716", + "size": 15648, + "url": "https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.5.1/JarJarMetadata-0.5.1.jar", + "path": "net/neoforged/JarJarMetadata/0.5.1/JarJarMetadata-0.5.1.jar" + } + } + }, + { + "name": "org.apache.maven:maven-artifact:3.9.16", + "downloads": { + "artifact": { + "sha1": "1176c6579a8a68508c3479ef72d514e71c98ddca", + "size": 58857, + "url": "https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.9.16/maven-artifact-3.9.16.jar", + "path": "org/apache/maven/maven-artifact/3.9.16/maven-artifact-3.9.16.jar" + } + } + }, + { + "name": "net.jodah:typetools:0.6.3", + "downloads": { + "artifact": { + "sha1": "a01aaa6ddaea9ec07ec4f209487b7a46a526283a", + "size": 18281, + "url": "https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar", + "path": "net/jodah/typetools/0.6.3/typetools-0.6.3.jar" + } + } + }, + { + "name": "net.minecrell:terminalconsoleappender:1.3.0", + "downloads": { + "artifact": { + "sha1": "b562e9bb61235c9520e26282cdee71f8f802d1fc", + "size": 16949, + "url": "https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar", + "path": "net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar" + } + } + }, + { + "name": "net.fabricmc:sponge-mixin:0.17.4+mixin.0.8.7", + "downloads": { + "artifact": { + "sha1": "5f66cc9f59b8efaa942155a3d5a30599bf6640dd", + "size": 1539080, + "url": "https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.17.4+mixin.0.8.7/sponge-mixin-0.17.4+mixin.0.8.7.jar", + "path": "net/fabricmc/sponge-mixin/0.17.4+mixin.0.8.7/sponge-mixin-0.17.4+mixin.0.8.7.jar" + } + } + }, + { + "name": "org.lwjgl:lwjgl-sdl:3.4.3", + "downloads": { + "artifact": { + "sha1": "c02146735d6ff70d070c73532b435e5c893a346b", + "size": 989769, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3.jar", + "path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3.jar" + } + } + }, + { + "name": "net.neoforged.accesstransformers:at-parser:11.0.2", + "downloads": { + "artifact": { + "sha1": "64aae57d6d26d69543c5275181fe6971707a4e16", + "size": 19331, + "url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-parser/11.0.2/at-parser-11.0.2.jar", + "path": "net/neoforged/accesstransformers/at-parser/11.0.2/at-parser-11.0.2.jar" + } + } + }, + { + "name": "net.neoforged:srgutils:1.0.10", + "downloads": { + "artifact": { + "sha1": "abb471237b33b19f040ada00bfbad1ec45b949b6", + "size": 72018, + "url": "https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.10/srgutils-1.0.10.jar", + "path": "net/neoforged/srgutils/1.0.10/srgutils-1.0.10.jar" + } + } + }, + { + "name": "org.codehaus.plexus:plexus-utils:3.6.1", + "downloads": { + "artifact": { + "sha1": "9dce1b9c69b197f453223e45a2cb9fa0cd48b424", + "size": 272101, + "url": "https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.6.1/plexus-utils-3.6.1.jar", + "path": "org/codehaus/plexus/plexus-utils/3.6.1/plexus-utils-3.6.1.jar" + } + } + }, + { + "name": "org.jline:jline-reader:3.20.0", + "downloads": { + "artifact": { + "sha1": "8f15415b022a25b473e8e16c28ae913186ffb9c4", + "size": 171052, + "url": "https://maven.neoforged.net/releases/org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar", + "path": "org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar" + } + } + }, + { + "name": "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", + "downloads": { + "artifact": { + "sha1": "b421526c5f297295adef1c886e5246c39d4ac629", + "size": 2199, + "url": "https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", + "path": "com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" + } + } + }, + { + "name": "com.google.errorprone:error_prone_annotations:2.48.0", + "downloads": { + "artifact": { + "sha1": "bbe30942a31d1778c74cb272321267eca82dc708", + "size": 20255, + "url": "https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.48.0/error_prone_annotations-2.48.0.jar", + "path": "com/google/errorprone/error_prone_annotations/2.48.0/error_prone_annotations-2.48.0.jar" + } + } + }, + { + "name": "com.google.j2objc:j2objc-annotations:3.1", + "downloads": { + "artifact": { + "sha1": "a892ca9507839bbdb900d64310ac98256cab992f", + "size": 16762, + "url": "https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/3.1/j2objc-annotations-3.1.jar", + "path": "com/google/j2objc/j2objc-annotations/3.1/j2objc-annotations-3.1.jar" + } + } + }, + { + "name": "org.jline:jline-terminal:3.20.0", + "downloads": { + "artifact": { + "sha1": "d0ddcc708ddf527a3454c941b7b9225cc83a15ff", + "size": 228481, + "url": "https://maven.neoforged.net/releases/org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar", + "path": "org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar" + } + } + }, + { + "name": "net.neoforged.installertools:installertools:4.0.17:fatjar", + "downloads": { + "artifact": { + "sha1": "d0e22dfef7eb7eeffa20fee4c393b3f7cad0323b", + "size": 814132, + "url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/4.0.17/installertools-4.0.17-fatjar.jar", + "path": "net/neoforged/installertools/installertools/4.0.17/installertools-4.0.17-fatjar.jar" + } + } + }, + { + "name": "net.neoforged:neoforge:26.3.0.46-beta:universal", + "downloads": { + "artifact": { + "sha1": "9f14fcd5ea1a589a436da095a3b03472d9d032c6", + "size": 7096738, + "url": "https://maven.neoforged.net/releases/net/neoforged/neoforge/26.3.0.46-beta/neoforge-26.3.0.46-beta-universal.jar", + "path": "net/neoforged/neoforge/26.3.0.46-beta/neoforge-26.3.0.46-beta-universal.jar" + } + } + } + ], + "serverJarPath": "{LIBRARY_DIR}/net/minecraft/server/{MINECRAFT_VERSION}/server-{MINECRAFT_VERSION}.jar" +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/neoforge-26.3-version.json b/src/test/resources/native-launcher/neoforge-26.3-version.json new file mode 100644 index 00000000..fdebba7c --- /dev/null +++ b/src/test/resources/native-launcher/neoforge-26.3-version.json @@ -0,0 +1,314 @@ +{ + "id": "neoforge-26.3.0.46-beta", + "time": "2026-10-03T16:36:59.231561549", + "releaseTime": "2026-10-03T16:36:59.231561549", + "type": "release", + "mainClass": "net.neoforged.fml.startup.Client", + "inheritsFrom": "26.3", + "arguments": { + "game": [ + "--fml.neoForgeVersion", + "26.3.0.46-beta", + "--fml.mcVersion", + "26.3", + "--fml.neoFormVersion", + "1" + ], + "jvm": [ + "-Djava.net.preferIPv6Addresses=system", + "-DlibraryDirectory=${library_directory}", + "--add-opens", + "java.base/java.lang.invoke=ALL-UNNAMED", + "--add-exports", + "jdk.naming.dns/com.sun.jndi.dns=java.naming" + ] + }, + "libraries": [ + { + "name": "net.neoforged.fancymodloader:earlydisplay:12.0.8", + "downloads": { + "artifact": { + "sha1": "efc16b2939a51b13aaeb500eff4caa82f2ac9d08", + "size": 394059, + "url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/12.0.8/earlydisplay-12.0.8.jar", + "path": "net/neoforged/fancymodloader/earlydisplay/12.0.8/earlydisplay-12.0.8.jar" + } + } + }, + { + "name": "net.neoforged.fancymodloader:loader:12.0.8", + "downloads": { + "artifact": { + "sha1": "176019d74cae7a468f2069ffc3c924f6a6f70a63", + "size": 673587, + "url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/12.0.8/loader-12.0.8.jar", + "path": "net/neoforged/fancymodloader/loader/12.0.8/loader-12.0.8.jar" + } + } + }, + { + "name": "net.neoforged:accesstransformers:11.0.2", + "downloads": { + "artifact": { + "sha1": "25dcb69c78b16669935f0ab91dff830ce192379d", + "size": 20292, + "url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/11.0.2/accesstransformers-11.0.2.jar", + "path": "net/neoforged/accesstransformers/11.0.2/accesstransformers-11.0.2.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-commons:9.10.1", + "downloads": { + "artifact": { + "sha1": "4229e4c55fd8e01c23f9fe9884075cc628aacc50", + "size": 74840, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar", + "path": "org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar" + } + } + }, + { + "name": "net.neoforged:mergetool:2.0.7:api", + "downloads": { + "artifact": { + "sha1": "793b18456e92c4a7f66d9b5d8bf7a9c1c7a20b20", + "size": 2550, + "url": "https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.7/mergetool-2.0.7-api.jar", + "path": "net/neoforged/mergetool/2.0.7/mergetool-2.0.7-api.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-util:9.10.1", + "downloads": { + "artifact": { + "sha1": "7bb9d450e8d4cbf9f9e04096c44bbfe7fba80b15", + "size": 95628, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar", + "path": "org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-analysis:9.10.1", + "downloads": { + "artifact": { + "sha1": "8d49f14d51f632cb1d87c88d1ceaf50db0d8af1b", + "size": 35140, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar", + "path": "org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar" + } + } + }, + { + "name": "org.ow2.asm:asm-tree:9.10.1", + "downloads": { + "artifact": { + "sha1": "e244332a17564c1d1572449399a842de35881be2", + "size": 51958, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "path": "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar" + } + } + }, + { + "name": "net.neoforged:bus:8.0.5", + "downloads": { + "artifact": { + "sha1": "5b2d33285ab5d1554e9798ad98c40d6ea3868bd5", + "size": 30340, + "url": "https://maven.neoforged.net/releases/net/neoforged/bus/8.0.5/bus-8.0.5.jar", + "path": "net/neoforged/bus/8.0.5/bus-8.0.5.jar" + } + } + }, + { + "name": "org.ow2.asm:asm:9.10.1", + "downloads": { + "artifact": { + "sha1": "ada2141c0cc52ee8f5c48cd5fa4ce0e794f22236", + "size": 126151, + "url": "https://maven.neoforged.net/releases/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "path": "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar" + } + } + }, + { + "name": "com.electronwill.night-config:toml:3.9.0", + "downloads": { + "artifact": { + "sha1": "8548891033db17e8f219b453cac38fb865ee4224", + "size": 43254, + "url": "https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.9.0/toml-3.9.0.jar", + "path": "com/electronwill/night-config/toml/3.9.0/toml-3.9.0.jar" + } + } + }, + { + "name": "com.electronwill.night-config:core:3.9.0", + "downloads": { + "artifact": { + "sha1": "29bba7ced5c9768adda06384c61fff2c33dc5e05", + "size": 381299, + "url": "https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.9.0/core-3.9.0.jar", + "path": "com/electronwill/night-config/core/3.9.0/core-3.9.0.jar" + } + } + }, + { + "name": "net.neoforged:JarJarSelector:0.5.1", + "downloads": { + "artifact": { + "sha1": "fb620ee1e164b0d4e527759df69317237c51133d", + "size": 17083, + "url": "https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.5.1/JarJarSelector-0.5.1.jar", + "path": "net/neoforged/JarJarSelector/0.5.1/JarJarSelector-0.5.1.jar" + } + } + }, + { + "name": "net.neoforged:JarJarMetadata:0.5.1", + "downloads": { + "artifact": { + "sha1": "09344da1c9bab76064f484a0d5921a1ca290a716", + "size": 15648, + "url": "https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.5.1/JarJarMetadata-0.5.1.jar", + "path": "net/neoforged/JarJarMetadata/0.5.1/JarJarMetadata-0.5.1.jar" + } + } + }, + { + "name": "org.apache.maven:maven-artifact:3.9.16", + "downloads": { + "artifact": { + "sha1": "1176c6579a8a68508c3479ef72d514e71c98ddca", + "size": 58857, + "url": "https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.9.16/maven-artifact-3.9.16.jar", + "path": "org/apache/maven/maven-artifact/3.9.16/maven-artifact-3.9.16.jar" + } + } + }, + { + "name": "net.jodah:typetools:0.6.3", + "downloads": { + "artifact": { + "sha1": "a01aaa6ddaea9ec07ec4f209487b7a46a526283a", + "size": 18281, + "url": "https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar", + "path": "net/jodah/typetools/0.6.3/typetools-0.6.3.jar" + } + } + }, + { + "name": "net.minecrell:terminalconsoleappender:1.3.0", + "downloads": { + "artifact": { + "sha1": "b562e9bb61235c9520e26282cdee71f8f802d1fc", + "size": 16949, + "url": "https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar", + "path": "net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar" + } + } + }, + { + "name": "net.fabricmc:sponge-mixin:0.17.4+mixin.0.8.7", + "downloads": { + "artifact": { + "sha1": "5f66cc9f59b8efaa942155a3d5a30599bf6640dd", + "size": 1539080, + "url": "https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.17.4+mixin.0.8.7/sponge-mixin-0.17.4+mixin.0.8.7.jar", + "path": "net/fabricmc/sponge-mixin/0.17.4+mixin.0.8.7/sponge-mixin-0.17.4+mixin.0.8.7.jar" + } + } + }, + { + "name": "net.neoforged.accesstransformers:at-parser:11.0.2", + "downloads": { + "artifact": { + "sha1": "64aae57d6d26d69543c5275181fe6971707a4e16", + "size": 19331, + "url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-parser/11.0.2/at-parser-11.0.2.jar", + "path": "net/neoforged/accesstransformers/at-parser/11.0.2/at-parser-11.0.2.jar" + } + } + }, + { + "name": "net.neoforged:srgutils:1.0.10", + "downloads": { + "artifact": { + "sha1": "abb471237b33b19f040ada00bfbad1ec45b949b6", + "size": 72018, + "url": "https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.10/srgutils-1.0.10.jar", + "path": "net/neoforged/srgutils/1.0.10/srgutils-1.0.10.jar" + } + } + }, + { + "name": "org.codehaus.plexus:plexus-utils:3.6.1", + "downloads": { + "artifact": { + "sha1": "9dce1b9c69b197f453223e45a2cb9fa0cd48b424", + "size": 272101, + "url": "https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.6.1/plexus-utils-3.6.1.jar", + "path": "org/codehaus/plexus/plexus-utils/3.6.1/plexus-utils-3.6.1.jar" + } + } + }, + { + "name": "org.jline:jline-reader:3.20.0", + "downloads": { + "artifact": { + "sha1": "8f15415b022a25b473e8e16c28ae913186ffb9c4", + "size": 171052, + "url": "https://maven.neoforged.net/releases/org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar", + "path": "org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar" + } + } + }, + { + "name": "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", + "downloads": { + "artifact": { + "sha1": "b421526c5f297295adef1c886e5246c39d4ac629", + "size": 2199, + "url": "https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar", + "path": "com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" + } + } + }, + { + "name": "com.google.errorprone:error_prone_annotations:2.48.0", + "downloads": { + "artifact": { + "sha1": "bbe30942a31d1778c74cb272321267eca82dc708", + "size": 20255, + "url": "https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.48.0/error_prone_annotations-2.48.0.jar", + "path": "com/google/errorprone/error_prone_annotations/2.48.0/error_prone_annotations-2.48.0.jar" + } + } + }, + { + "name": "com.google.j2objc:j2objc-annotations:3.1", + "downloads": { + "artifact": { + "sha1": "a892ca9507839bbdb900d64310ac98256cab992f", + "size": 16762, + "url": "https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/3.1/j2objc-annotations-3.1.jar", + "path": "com/google/j2objc/j2objc-annotations/3.1/j2objc-annotations-3.1.jar" + } + } + }, + { + "name": "org.jline:jline-terminal:3.20.0", + "downloads": { + "artifact": { + "sha1": "d0ddcc708ddf527a3454c941b7b9225cc83a15ff", + "size": 228481, + "url": "https://maven.neoforged.net/releases/org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar", + "path": "org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar" + } + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/neoforge-install_profile.json b/src/test/resources/native-launcher/neoforge-install_profile.json new file mode 100644 index 00000000..235164ed --- /dev/null +++ b/src/test/resources/native-launcher/neoforge-install_profile.json @@ -0,0 +1 @@ +{"spec":1,"profile":"NeoForge","version":"neoforge-20.4.251","icon":"data:image/png;base64,AAABAAEAgIAAAAEAIAAoCAEAFgAAACgAAACAAAAAAAEAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIB1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIB1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/qaWg/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+ppaD/qaWg/6mloP+ppaD/qaWg/6mloP+ppaD/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG///T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG///T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG///T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/xoVE/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/GhUT/xoVE/8aFRP/GhUT/xoVE/8aFRP/GhUT/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/Myom/zMqJv8zKib/Myom/zMqJv8zKib/Myom/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8zKib/Myom/zMqJv8zKib/Myom/zMqJv8zKib/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG///T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8zKib/Myom/zMqJv8zKib/Myom/zMqJv8zKib/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zMqJv8zKib/Myom/zMqJv8zKib/Myom/zMqJv/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zMqJv8zKib/Myom/zMqJv8zKib/Myom/zMqJv8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/Myom/zMqJv8zKib/Myom/zMqJv8zKib/Myom//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/Myom/zMqJv8zKib/Myom/zMqJv8zKib/Myom/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8zKib/Myom/zMqJv8zKib/Myom/zMqJv8zKib/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG///T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8zKib/Myom/zMqJv8zKib/Myom/zMqJv8zKib/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zMqJv8zKib/Myom/zMqJv8zKib/Myom/zMqJv/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zMqJv8zKib/Myom/zMqJv8zKib/Myom/zMqJv8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/Myom/zMqJv8zKib/Myom/zMqJv8zKib/Myom//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/Myom/zMqJv8zKib/Myom/zMqJv8zKib/Myom/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8zKib/Myom/zMqJv8zKib/Myom/zMqJv8zKib/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/gHV1/4B1df+AdXX/gHV1/4B1df+AdXX/gHV1/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX//T0+f/09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//09Pn/9PT5//T0+f/09Pn/9PT5//T0+f/09Pn/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/L3TX/y901/8vdNf/L3TX/y901/8vdNf/L3TX/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v8vdNf/L3TX/y901/8vdNf/L3TX/y901/8vdNf/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/zeM5v83jOb/N4zm/y901/8vdNf/L3TX/y901/8vdNf/L3TX/y901/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/+5o8f/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//NGG//zRhv/80Yb//N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/7mjx/+5o8f/uaPH/7mjx/+5o8f/uaPH/7mjx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/9PZ5/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/09nn/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/zdOpP83TqT/N06k/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/T2ef/09nn/9PZ5//T2ef/09nn/9PZ5//T2ef/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9ocY3/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/2hxjf9ocY3/aHGN/2hxjf9ocY3/aHGN/2hxjf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx/9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/s7Cq/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/0dHH/9HRx//R0cf/0dHH/9HRx//R0cf/0dHH/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/01TZv9NU2b/TVNm/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/8fHvf+zsKr/s7Cq/7Owqv+zsKr/s7Cq/7Owqv+zsKr/x8e9/8fHvf/Hx73/x8e9/8fHvf/Hx73/x8e9/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi//f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/x8ub/8fLm//Hy5v/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/3+DV/9/g1f/f4NX/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/+Ujov/lI6L/5SOi/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////////////////////////////////////////////////4AAAAAAAAAAAAAAAAAAH/+AAAAAAAAAAAAAAAAAAB//gAAAAAAAAAAAAAAAAAAf/4AAAAAAAAAAAAAAAAAAH/+AAAAAAAAAAAAAAAAAAB//gAAAAAAAAAAAAAAAAAAf/4AAAAAAAAAAAAAAAAAAH8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4AAAAAAAAAAAAAAAAAAH/+AAAAAAAAAAAAAAAAAAB//gAAAAAAAAAAAAAAAAAAf/4AAAAAAAAAAAAAAAAAAH/+AAAAAAAAAAAAAAAAAAB//gAAAAAAAAAAAAAAAAAAf/4AAAAAAAAAAAAAAAAAAH////////////////////////////////////////////////////////////////8=","minecraft":"1.20.4","json":"/version.json","logo":"/big_logo.png","welcome":"Welcome to the simple NeoForge installer","mirrorList":"https://mirrors.neoforged.net","hideExtract":true,"data":{"MC_SLIM":{"client":"[net.minecraft:client:1.20.4-20240627.114801:slim]","server":"[net.minecraft:server:1.20.4-20240627.114801:slim]"},"MC_UNPACKED":{"client":"[net.minecraft:client:1.20.4-20240627.114801:unpacked]","server":"[net.minecraft:server:1.20.4-20240627.114801:unpacked]"},"MERGED_MAPPINGS":{"client":"[net.neoforged:neoform:1.20.4-20240627.114801:mappings-merged@txt]","server":"[net.neoforged:neoform:1.20.4-20240627.114801:mappings-merged@txt]"},"BINPATCH":{"client":"/data/client.lzma","server":"/data/server.lzma"},"MCP_VERSION":{"client":"'1.20.4-20240627.114801'","server":"'1.20.4-20240627.114801'"},"MAPPINGS":{"client":"[net.neoforged:neoform:1.20.4-20240627.114801:mappings@txt]","server":"[net.neoforged:neoform:1.20.4-20240627.114801:mappings@txt]"},"MC_EXTRA":{"client":"[net.minecraft:client:1.20.4-20240627.114801:extra]","server":"[net.minecraft:server:1.20.4-20240627.114801:extra]"},"MOJMAPS":{"client":"[net.minecraft:client:1.20.4-20240627.114801:mappings@txt]","server":"[net.minecraft:server:1.20.4-20240627.114801:mappings@txt]"},"PATCHED":{"client":"[net.neoforged:neoforge:20.4.251:client]","server":"[net.neoforged:neoforge:20.4.251:server]"},"MC_SRG":{"client":"[net.minecraft:client:1.20.4-20240627.114801:srg]","server":"[net.minecraft:server:1.20.4-20240627.114801:srg]"}},"processors":[{"sides":["server"],"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","EXTRACT_FILES","--archive","{INSTALLER}","--from","data/run.sh","--to","{ROOT}/run.sh","--exec","{ROOT}/run.sh","--from","data/run.bat","--to","{ROOT}/run.bat","--from","data/user_jvm_args.txt","--to","{ROOT}/user_jvm_args.txt","--optional","{ROOT}/user_jvm_args.txt","--from","data/win_args.txt","--to","{ROOT}/libraries/net/neoforged/neoforge/20.4.251/win_args.txt","--from","data/unix_args.txt","--to","{ROOT}/libraries/net/neoforged/neoforge/20.4.251/unix_args.txt"]},{"sides":["server"],"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","BUNDLER_EXTRACT","--input","{MINECRAFT_JAR}","--output","{ROOT}/libraries/","--libraries"]},{"sides":["server"],"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","BUNDLER_EXTRACT","--input","{MINECRAFT_JAR}","--output","{MC_UNPACKED}","--jar-only"]},{"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","MCP_DATA","--input","[net.neoforged:neoform:1.20.4-20240627.114801@zip]","--output","{MAPPINGS}","--key","mappings"]},{"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","DOWNLOAD_MOJMAPS","--version","1.20.4","--side","{SIDE}","--output","{MOJMAPS}"]},{"jar":"net.neoforged.installertools:installertools:2.1.2","classpath":["org.ow2.asm:asm:9.3@jar","net.md-5:SpecialSource:1.11.0@jar","net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:installertools:2.1.2@jar","com.google.code.gson:gson:2.8.9@jar","org.ow2.asm:asm-tree:9.3@jar","com.opencsv:opencsv:4.4@jar","net.neoforged:srgutils:1.0.0@jar","org.apache.commons:commons-text:1.3@jar","de.siegmar:fastcsv:2.0.0@jar","org.apache.commons:commons-lang3:3.8.1@jar","org.apache.commons:commons-collections4:4.2@jar","org.ow2.asm:asm-commons:9.3@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","com.google.guava:guava:20.0@jar","commons-beanutils:commons-beanutils:1.9.3@jar","org.ow2.asm:asm-analysis:9.3@jar","commons-collections:commons-collections:3.2.2@jar","commons-logging:commons-logging:1.2@jar"],"args":["--task","MERGE_MAPPING","--left","{MAPPINGS}","--right","{MOJMAPS}","--output","{MERGED_MAPPINGS}","--classes","--fields","--methods","--reverse-right"]},{"sides":["client"],"jar":"net.neoforged.installertools:jarsplitter:2.1.2","classpath":["net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:jarsplitter:2.1.2@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","net.neoforged:srgutils:1.0.0@jar"],"args":["--input","{MINECRAFT_JAR}","--slim","{MC_SLIM}","--extra","{MC_EXTRA}","--srg","{MERGED_MAPPINGS}"]},{"sides":["server"],"jar":"net.neoforged.installertools:jarsplitter:2.1.2","classpath":["net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:jarsplitter:2.1.2@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","net.neoforged:srgutils:1.0.0@jar"],"args":["--input","{MC_UNPACKED}","--slim","{MC_SLIM}","--extra","{MC_EXTRA}","--srg","{MERGED_MAPPINGS}"]},{"jar":"net.neoforged:AutoRenamingTool:1.0.13:all","classpath":["org.ow2.asm:asm-commons:9.5@jar","net.minecraftforge:srgutils:0.5.1@jar","org.ow2.asm:asm:9.5@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","net.sf.jopt-simple:jopt-simple:6.0-alpha-3@jar","net.neoforged.javadoctor:gson-io:2.0.0@jar","com.google.code.gson:gson:2.10.1@jar","net.neoforged.javadoctor:spec:2.0.0@jar","net.neoforged:AutoRenamingTool:1.0.13:all@jar","org.ow2.asm:asm-tree:9.5@jar"],"args":["--input","{MC_SLIM}","--output","{MC_SRG}","--names","{MERGED_MAPPINGS}","--ann-fix","--ids-fix","--src-fix","--record-fix"]},{"jar":"net.neoforged.installertools:binarypatcher:2.1.2","classpath":["net.sf.jopt-simple:jopt-simple:5.0.4@jar","net.neoforged.installertools:binarypatcher:2.1.2@jar","trove:trove:1.0.2@jar","net.neoforged.installertools:cli-utils:2.1.2@jar","net.neoforged:srgutils:1.0.0@jar","com.github.jponge:lzma-java:1.3@jar","com.nothome:javaxdelta:2.0.1@jar"],"args":["--clean","{MC_SRG}","--output","{PATCHED}","--apply","{BINPATCH}"]}],"libraries":[{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:installertools:2.1.2@jar","downloads":{"artifact":{"sha1":"72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b","size":83543,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar","path":"net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar"}}},{"name":"net.md-5:SpecialSource:1.11.0@jar","downloads":{"artifact":{"sha1":"43b84c4bb8d01cf90a29dfeec1c958a4b64baf46","size":88737,"url":"https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar","path":"net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar"}}},{"name":"org.ow2.asm:asm-commons:9.3@jar","downloads":{"artifact":{"sha1":"1f2a432d1212f5c352ae607d7b61dcae20c20af5","size":72716,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar","path":"org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.3@jar","downloads":{"artifact":{"sha1":"4b071f211b37c38e0e9f5998550197c8593f6ad8","size":34276,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar","path":"org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar"}}},{"name":"org.ow2.asm:asm-tree:9.3@jar","downloads":{"artifact":{"sha1":"78d2ecd61318b5a58cd04fb237636c0e86b77d97","size":52669,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar","path":"org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar"}}},{"name":"org.ow2.asm:asm:9.3@jar","downloads":{"artifact":{"sha1":"8e6300ef51c1d801a7ed62d07cd221aca3a90640","size":122176,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar","path":"org/ow2/asm/asm/9.3/asm-9.3.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.google.code.gson:gson:2.8.9@jar","downloads":{"artifact":{"sha1":"8a432c1d6825781e21a02db2e2c33c5fde2833b9","size":258075,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar","path":"com/google/code/gson/gson/2.8.9/gson-2.8.9.jar"}}},{"name":"com.opencsv:opencsv:4.4@jar","downloads":{"artifact":{"sha1":"525932a1edf46c9ca75aa9c83d31dc180c186865","size":167167,"url":"https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar","path":"com/opencsv/opencsv/4.4/opencsv-4.4.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"org.apache.commons:commons-text:1.3@jar","downloads":{"artifact":{"sha1":"9abf61708a66ab5e55f6169a200dbfc584b546d9","size":182954,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar","path":"org/apache/commons/commons-text/1.3/commons-text-1.3.jar"}}},{"name":"de.siegmar:fastcsv:2.0.0@jar","downloads":{"artifact":{"sha1":"b615f26c03edeac966618b93b9ee4f4eed50aae1","size":29476,"url":"https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar","path":"de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar"}}},{"name":"org.apache.commons:commons-lang3:3.8.1@jar","downloads":{"artifact":{"sha1":"6505a72a097d9270f7a9e7bf42c4238283247755","size":501879,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar","path":"org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar"}}},{"name":"org.apache.commons:commons-collections4:4.2@jar","downloads":{"artifact":{"sha1":"54ebea0a5b653d3c680131e73fe807bb8f78c4ed","size":752798,"url":"https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar","path":"org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"com.google.guava:guava:20.0@jar","downloads":{"artifact":{"sha1":"89507701249388e1ed5ddcf8c41f4ce1be7831ef","size":2442625,"url":"https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar","path":"com/google/guava/guava/20.0/guava-20.0.jar"}}},{"name":"commons-beanutils:commons-beanutils:1.9.3@jar","downloads":{"artifact":{"sha1":"c845703de334ddc6b4b3cd26835458cb1cba1f3d","size":246174,"url":"https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar","path":"commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar"}}},{"name":"commons-collections:commons-collections:3.2.2@jar","downloads":{"artifact":{"sha1":"8ad72fe39fa8c91eaaf12aadb21e0c3661fe26d5","size":588337,"url":"https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar","path":"commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar"}}},{"name":"commons-logging:commons-logging:1.2@jar","downloads":{"artifact":{"sha1":"4bfc12adfe4842bf07b657f0369c4cb522955686","size":61829,"url":"https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar","path":"commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}},{"name":"net.neoforged.installertools:jarsplitter:2.1.2@jar","downloads":{"artifact":{"sha1":"8a7916be0a0e589897beab7c839072631067e48e","size":7790,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar","path":"net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"net.neoforged.installertools:jarsplitter:2.1.2@jar","downloads":{"artifact":{"sha1":"8a7916be0a0e589897beab7c839072631067e48e","size":7790,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar","path":"net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"net.neoforged:AutoRenamingTool:1.0.13:all@jar","downloads":{"artifact":{"sha1":"1a208be23ed1b13d158668a602a2e08bb9ae72e0","size":751197,"url":"https://maven.neoforged.net/releases/net/neoforged/AutoRenamingTool/1.0.13/AutoRenamingTool-1.0.13-all.jar","path":"net/neoforged/AutoRenamingTool/1.0.13/AutoRenamingTool-1.0.13-all.jar"}}},{"name":"org.ow2.asm:asm-commons:9.5@jar","downloads":{"artifact":{"sha1":"19ab5b5800a3910d30d3a3e64fdb00fd0cb42de0","size":72209,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.5/asm-commons-9.5.jar","path":"org/ow2/asm/asm-commons/9.5/asm-commons-9.5.jar"}}},{"name":"net.minecraftforge:srgutils:0.5.1@jar","downloads":{"artifact":{"sha1":"2268068226f4fc241be0b4592758dc0fc8371837","size":69815,"url":"https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.5.1/srgutils-0.5.1.jar","path":"net/minecraftforge/srgutils/0.5.1/srgutils-0.5.1.jar"}}},{"name":"org.ow2.asm:asm-tree:9.5@jar","downloads":{"artifact":{"sha1":"fd33c8b6373abaa675be407082fdfda35021254a","size":51944,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.5/asm-tree-9.5.jar","path":"org/ow2/asm/asm-tree/9.5/asm-tree-9.5.jar"}}},{"name":"org.ow2.asm:asm:9.5@jar","downloads":{"artifact":{"sha1":"dc6ea1875f4d64fbc85e1691c95b96a3d8569c90","size":121863,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm/9.5/asm-9.5.jar","path":"org/ow2/asm/asm/9.5/asm-9.5.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:6.0-alpha-3@jar","downloads":{"artifact":{"sha1":"02ab1a73e1e22d2b73469362a0908a98644b681d","size":79368,"url":"https://maven.neoforged.net/releases/net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar","path":"net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar"}}},{"name":"net.neoforged.javadoctor:gson-io:2.0.0@jar","downloads":{"artifact":{"sha1":"84f94fa83ac3218e5b89802f2deec1115c0cbaa0","size":5747,"url":"https://maven.neoforged.net/releases/net/neoforged/javadoctor/gson-io/2.0.0/gson-io-2.0.0.jar","path":"net/neoforged/javadoctor/gson-io/2.0.0/gson-io-2.0.0.jar"}}},{"name":"com.google.code.gson:gson:2.10.1@jar","downloads":{"artifact":{"sha1":"b3add478d4382b78ea20b1671390a858002feb6c","size":283367,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar","path":"com/google/code/gson/gson/2.10.1/gson-2.10.1.jar"}}},{"name":"net.neoforged.javadoctor:spec:2.0.0@jar","downloads":{"artifact":{"sha1":"8f19d9fc4e4bd83eaad8a5309e8c849ee1234f88","size":6227,"url":"https://maven.neoforged.net/releases/net/neoforged/javadoctor/spec/2.0.0/spec-2.0.0.jar","path":"net/neoforged/javadoctor/spec/2.0.0/spec-2.0.0.jar"}}},{"name":"net.neoforged.installertools:binarypatcher:2.1.2@jar","downloads":{"artifact":{"sha1":"eab75026876e127d35d41a6dc3a91c839070e2e0","size":23074,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/binarypatcher/2.1.2/binarypatcher-2.1.2.jar","path":"net/neoforged/installertools/binarypatcher/2.1.2/binarypatcher-2.1.2.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"com.nothome:javaxdelta:2.0.1@jar","downloads":{"artifact":{"sha1":"d4f5d077f1b15c6f67595015d69572303ad3f66d","size":38995,"url":"https://maven.neoforged.net/releases/com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar","path":"com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar"}}},{"name":"trove:trove:1.0.2@jar","downloads":{"artifact":{"sha1":"112b40581ab9a4d3de4636d49985a7f686d181c8","size":361155,"url":"https://maven.neoforged.net/releases/trove/trove/1.0.2/trove-1.0.2.jar","path":"trove/trove/1.0.2/trove-1.0.2.jar"}}},{"name":"net.neoforged.installertools:cli-utils:2.1.2@jar","downloads":{"artifact":{"sha1":"e5a3115eb9e299117472675946be8a72e4990ad6","size":9379,"url":"https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar","path":"net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar"}}},{"name":"net.neoforged:srgutils:1.0.0@jar","downloads":{"artifact":{"sha1":"b9fe6cdab494983217cbc14cc6f92c8e6c616526","size":71421,"url":"https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar","path":"net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar"}}},{"name":"com.github.jponge:lzma-java:1.3@jar","downloads":{"artifact":{"sha1":"a25db9d4d385ccda4825ae1b47a7a61d86e595af","size":51041,"url":"https://maven.neoforged.net/releases/com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar","path":"com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar"}}},{"name":"net.neoforged:neoforge:20.4.251:universal","downloads":{"artifact":{"sha1":"23509585d7a6a397e966b045737d764839d7b095","size":2611870,"url":"https://maven.neoforged.net/releases/net/neoforged/neoforge/20.4.251/neoforge-20.4.251-universal.jar","path":"net/neoforged/neoforge/20.4.251/neoforge-20.4.251-universal.jar"}}},{"name":"net.neoforged.fancymodloader:earlydisplay:2.0.17@jar","downloads":{"artifact":{"sha1":"d6af13e1290e79c7e4501a097cdd1bd50531f5f3","size":262052,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/2.0.17/earlydisplay-2.0.17.jar","path":"net/neoforged/fancymodloader/earlydisplay/2.0.17/earlydisplay-2.0.17.jar"}}},{"name":"net.neoforged.fancymodloader:loader:2.0.17@jar","downloads":{"artifact":{"sha1":"e187e949dea14726924c284ef8bf6d51e4c87526","size":419246,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/2.0.17/loader-2.0.17.jar","path":"net/neoforged/fancymodloader/loader/2.0.17/loader-2.0.17.jar"}}},{"name":"net.neoforged:coremods:6.0.4@jar","downloads":{"artifact":{"sha1":"87c17499d9fd2d5c9a220ec653f0e674fb25ccaa","size":24182,"url":"https://maven.neoforged.net/releases/net/neoforged/coremods/6.0.4/coremods-6.0.4.jar","path":"net/neoforged/coremods/6.0.4/coremods-6.0.4.jar"}}},{"name":"net.neoforged.fancymodloader:spi:2.0.17@jar","downloads":{"artifact":{"sha1":"3d3f2ed71a472bb360a38eaad565cec755696406","size":31942,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/spi/2.0.17/spi-2.0.17.jar","path":"net/neoforged/fancymodloader/spi/2.0.17/spi-2.0.17.jar"}}},{"name":"cpw.mods:modlauncher:10.0.9@jar","downloads":{"artifact":{"sha1":"06d9443f56f50bb85cea383686ff9c867391458b","size":130343,"url":"https://maven.neoforged.net/releases/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar","path":"cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}},{"name":"cpw.mods:securejarhandler:2.1.24@jar","downloads":{"artifact":{"sha1":"21e16217c30688d99200796f5d828ad5b671141c","size":103854,"url":"https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.24/securejarhandler-2.1.24.jar","path":"cpw/mods/securejarhandler/2.1.24/securejarhandler-2.1.24.jar"}}},{"name":"net.neoforged.accesstransformers:at-modlauncher:10.0.1@jar","downloads":{"artifact":{"sha1":"5aba50202aceead086bc09fbc2751c9f05cb4890","size":2347,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar","path":"net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar"}}},{"name":"net.neoforged:accesstransformers:10.0.1@jar","downloads":{"artifact":{"sha1":"fd83b5725f76eae9115e9355fa1c456a6a441400","size":67234,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar","path":"net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar"}}},{"name":"org.openjdk.nashorn:nashorn-core:15.4@jar","downloads":{"artifact":{"sha1":"f67f5ffaa5f5130cf6fb9b133da00c7df3b532a5","size":2167292,"url":"https://maven.neoforged.net/releases/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar","path":"org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar"}}},{"name":"org.ow2.asm:asm-commons:9.8@jar","downloads":{"artifact":{"sha1":"36e4d212970388e5bd2c5180292012502df461bb","size":73498,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar","path":"org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar"}}},{"name":"net.neoforged:mergetool:2.0.0:api@jar","downloads":{"artifact":{"sha1":"52fe1949be64e3303aabaaa21e315f551db9c9f4","size":2550,"url":"https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar","path":"net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar"}}},{"name":"org.ow2.asm:asm-util:9.8@jar","downloads":{"artifact":{"sha1":"395f1c1f035258511f27bc9b2583d76e4b143f59","size":94559,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.8/asm-util-9.8.jar","path":"org/ow2/asm/asm-util/9.8/asm-util-9.8.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.8@jar","downloads":{"artifact":{"sha1":"b9747a320844b6cb1eacd90d8ecfd260a16c01d3","size":35151,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar","path":"org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar"}}},{"name":"org.ow2.asm:asm-tree:9.8@jar","downloads":{"artifact":{"sha1":"018419ca5b77a2f81097c741e7872e6ab8d2f40d","size":51934,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar","path":"org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar"}}},{"name":"net.neoforged:bus:7.2.0@jar","downloads":{"artifact":{"sha1":"03bc91f330d0e2f350ab1dbe5026a5769dc52b9a","size":35117,"url":"https://maven.neoforged.net/releases/net/neoforged/bus/7.2.0/bus-7.2.0.jar","path":"net/neoforged/bus/7.2.0/bus-7.2.0.jar"}}},{"name":"org.ow2.asm:asm:9.8@jar","downloads":{"artifact":{"sha1":"dc19ecb3f7889b7860697215cae99c0f9b6f6b4b","size":126113,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm/9.8/asm-9.8.jar","path":"org/ow2/asm/asm/9.8/asm-9.8.jar"}}},{"name":"net.minecraftforge:unsafe:0.2.0@jar","downloads":{"artifact":{"sha1":"54d7a0a5e8fdb71b973025caa46f341ae5904f39","size":2834,"url":"https://maven.neoforged.net/releases/net/minecraftforge/unsafe/0.2.0/unsafe-0.2.0.jar","path":"net/minecraftforge/unsafe/0.2.0/unsafe-0.2.0.jar"}}},{"name":"com.electronwill.night-config:toml:3.6.4@jar","downloads":{"artifact":{"sha1":"51d6cefb2b55ee55ee26b16391212fb2c7dfb4f4","size":31816,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.6.4/toml-3.6.4.jar","path":"com/electronwill/night-config/toml/3.6.4/toml-3.6.4.jar"}}},{"name":"com.electronwill.night-config:core:3.6.4@jar","downloads":{"artifact":{"sha1":"510f174abbf1c947494db50ef2445683bd52c230","size":199834,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.6.4/core-3.6.4.jar","path":"com/electronwill/night-config/core/3.6.4/core-3.6.4.jar"}}},{"name":"net.neoforged:JarJarSelector:0.4.0@jar","downloads":{"artifact":{"sha1":"122fa3fe21da1a0ace8323709197616cdb4d1416","size":17198,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.4.0/JarJarSelector-0.4.0.jar","path":"net/neoforged/JarJarSelector/0.4.0/JarJarSelector-0.4.0.jar"}}},{"name":"net.neoforged:JarJarMetadata:0.4.0@jar","downloads":{"artifact":{"sha1":"06f5e5c52382d11a15f47ca030e046519e389456","size":15676,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.4.0/JarJarMetadata-0.4.0.jar","path":"net/neoforged/JarJarMetadata/0.4.0/JarJarMetadata-0.4.0.jar"}}},{"name":"org.apache.maven:maven-artifact:3.8.5@jar","downloads":{"artifact":{"sha1":"4433f50c07debefaed0553bd0068f4f48d449313","size":58077,"url":"https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar","path":"org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar"}}},{"name":"net.jodah:typetools:0.6.3@jar","downloads":{"artifact":{"sha1":"a01aaa6ddaea9ec07ec4f209487b7a46a526283a","size":18281,"url":"https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar","path":"net/jodah/typetools/0.6.3/typetools-0.6.3.jar"}}},{"name":"net.minecrell:terminalconsoleappender:1.2.0@jar","downloads":{"artifact":{"sha1":"96d02cd3b384ff015a8fef4223bcb4ccf1717c95","size":15977,"url":"https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar","path":"net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar"}}},{"name":"net.fabricmc:sponge-mixin:0.13.4+mixin.0.8.5@jar","downloads":{"artifact":{"sha1":"8adf0cff1795a169141b9e16ad7b9f6e2527a990","size":1458874,"url":"https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.13.4+mixin.0.8.5/sponge-mixin-0.13.4+mixin.0.8.5.jar","path":"net/fabricmc/sponge-mixin/0.13.4+mixin.0.8.5/sponge-mixin-0.13.4+mixin.0.8.5.jar"}}},{"name":"org.apache.commons:commons-lang3:3.13.0@jar","downloads":{"artifact":{"sha1":"b7263237aa89c1f99b327197c41d0669707a462e","size":632267,"url":"https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar","path":"org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar"}}},{"name":"org.slf4j:slf4j-api:2.0.9@jar","downloads":{"artifact":{"sha1":"7cf2726fdcfbc8610f9a71fb3ed639871f315340","size":64579,"url":"https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar","path":"org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"}}},{"name":"net.neoforged:neoform:1.20.4-20240627.114801@zip","downloads":{"artifact":{"sha1":"1a85c9aabc4a0272b78cdca7090b806e50b41178","size":1785114,"url":"https://maven.neoforged.net/releases/net/neoforged/neoform/1.20.4-20240627.114801/neoform-1.20.4-20240627.114801.zip","path":"net/neoforged/neoform/1.20.4-20240627.114801/neoform-1.20.4-20240627.114801.zip"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"commons-io:commons-io:2.11.0@jar","downloads":{"artifact":{"sha1":"a2503f302b11ebde7ebc3df41daebe0e4eea3689","size":327135,"url":"https://libraries.minecraft.net/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar","path":"commons-io/commons-io/2.11.0/commons-io-2.11.0.jar"}}},{"name":"org.apache.logging.log4j:log4j-core:2.19.0@jar","downloads":{"artifact":{"sha1":"3b6eeb4de4c49c0fe38a4ee27188ff5fee44d0bb","size":1864386,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar","path":"org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar"}}},{"name":"org.apache.logging.log4j:log4j-api:2.19.0@jar","downloads":{"artifact":{"sha1":"ea1b37f38c327596b216542bc636cfdc0b8036fa","size":317566,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar","path":"org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar"}}},{"name":"com.google.guava:guava:31.1-jre@jar","downloads":{"artifact":{"sha1":"60458f877d055d0c9114d9e1a2efb737b4bc282c","size":2959479,"url":"https://libraries.minecraft.net/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar","path":"com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}},{"name":"com.google.code.gson:gson:2.10@jar","downloads":{"artifact":{"sha1":"dd9b193aef96e973d5a11ab13cd17430c2e4306b","size":286235,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.10/gson-2.10.jar","path":"com/google/code/gson/gson/2.10/gson-2.10.jar"}}},{"name":"com.mojang:logging:1.1.1@jar","downloads":{"artifact":{"sha1":"832b8e6674a9b325a5175a3a6267dfaf34c85139","size":15343,"url":"https://libraries.minecraft.net/com/mojang/logging/1.1.1/logging-1.1.1.jar","path":"com/mojang/logging/1.1.1/logging-1.1.1.jar"}}},{"name":"org.jetbrains:annotations:22.0.0@jar","downloads":{"artifact":{"sha1":"fb9bebc90794c7602e73d6ed0cb165a0c650be62","size":28482,"url":"https://maven.neoforged.net/releases/org/jetbrains/annotations/22.0.0/annotations-22.0.0.jar","path":"org/jetbrains/annotations/22.0.0/annotations-22.0.0.jar"}}},{"name":"org.antlr:antlr4-runtime:4.13.1@jar","downloads":{"artifact":{"sha1":"17125bae1d965624e265ef49552f6465a2bfa307","size":326305,"url":"https://maven.neoforged.net/releases/org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar","path":"org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar"}}},{"name":"net.minecraftforge:srgutils:0.4.15@jar","downloads":{"artifact":{"sha1":"ca408b131759478f164e010fae0d73997e125fb5","size":69393,"url":"https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar","path":"net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar"}}},{"name":"org.codehaus.plexus:plexus-utils:3.3.0@jar","downloads":{"artifact":{"sha1":"cf43b5391de623b36fe066a21127baef82c64022","size":263253,"url":"https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar","path":"org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar"}}},{"name":"org.jline:jline-reader:3.12.1@jar","downloads":{"artifact":{"sha1":"4382ab1382c7b6f379377ed5f665dc2f6e1218bc","size":150765,"url":"https://maven.neoforged.net/releases/org/jline/jline-reader/3.12.1/jline-reader-3.12.1.jar","path":"org/jline/jline-reader/3.12.1/jline-reader-3.12.1.jar"}}},{"name":"com.machinezoo.noexception:noexception:1.7.1@jar","downloads":{"artifact":{"sha1":"b65330c98e38a1f915fa54a6e5eca496505e3f0a","size":250861,"url":"https://maven.neoforged.net/releases/com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar","path":"com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar"}}},{"name":"com.google.guava:failureaccess:1.0.1@jar","downloads":{"artifact":{"sha1":"1dcf1de382a0bf95a3d8b0849546c88bac1292c9","size":4617,"url":"https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar","path":"com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}},{"name":"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava@jar","downloads":{"artifact":{"sha1":"b421526c5f297295adef1c886e5246c39d4ac629","size":2199,"url":"https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar","path":"com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar"}}},{"name":"com.google.code.findbugs:jsr305:3.0.2@jar","downloads":{"artifact":{"sha1":"25ea2e8b0c338a877313bd4672d3fe056ea78f0d","size":19936,"url":"https://libraries.minecraft.net/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar","path":"com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar"}}},{"name":"org.checkerframework:checker-qual:3.12.0@jar","downloads":{"artifact":{"sha1":"d5692f0526415fcc6de94bb5bfbd3afd9dd3b3e5","size":208835,"url":"https://libraries.minecraft.net/org/checkerframework/checker-qual/3.12.0/checker-qual-3.12.0.jar","path":"org/checkerframework/checker-qual/3.12.0/checker-qual-3.12.0.jar"}}},{"name":"com.google.errorprone:error_prone_annotations:2.11.0@jar","downloads":{"artifact":{"sha1":"c5a0ace696d3f8b1c1d8cc036d8c03cc0cbe6b69","size":15979,"url":"https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.11.0/error_prone_annotations-2.11.0.jar","path":"com/google/errorprone/error_prone_annotations/2.11.0/error_prone_annotations-2.11.0.jar"}}},{"name":"com.google.j2objc:j2objc-annotations:1.3@jar","downloads":{"artifact":{"sha1":"ba035118bc8bac37d7eff77700720999acd9986d","size":8781,"url":"https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar","path":"com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar"}}},{"name":"org.jline:jline-terminal:3.12.1@jar","downloads":{"artifact":{"sha1":"c777448314e050d980a6b697c140f3bfe9eb7416","size":211712,"url":"https://maven.neoforged.net/releases/org/jline/jline-terminal/3.12.1/jline-terminal-3.12.1.jar","path":"org/jline/jline-terminal/3.12.1/jline-terminal-3.12.1.jar"}}}],"serverJarPath":"{LIBRARY_DIR}/net/minecraft/server/{MINECRAFT_VERSION}/server-{MINECRAFT_VERSION}.jar"} \ No newline at end of file diff --git a/src/test/resources/native-launcher/neoforge-version.json b/src/test/resources/native-launcher/neoforge-version.json new file mode 100644 index 00000000..434dc956 --- /dev/null +++ b/src/test/resources/native-launcher/neoforge-version.json @@ -0,0 +1 @@ +{"id":"neoforge-20.4.251","time":"2025-10-22T15:08:00.788672793","releaseTime":"2025-10-22T15:08:00.788672793","type":"release","mainClass":"cpw.mods.bootstraplauncher.BootstrapLauncher","inheritsFrom":"1.20.4","arguments":{"game":["--fml.neoForgeVersion","20.4.251","--fml.fmlVersion","2.0.17","--fml.mcVersion","1.20.4","--fml.neoFormVersion","20240627.114801","--launchTarget","forgeclient"],"jvm":["-Djava.net.preferIPv6Addresses=system","-DignoreList=securejarhandler-2.1.24.jar,asm-9.8.jar,asm-commons-9.8.jar,asm-tree-9.8.jar,asm-util-9.8.jar,asm-analysis-9.8.jar,bootstraplauncher-1.1.2.jar,JarJarFileSystems-0.4.0.jar,client-extra,neoforge-,${version_name}.jar","-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar","-Dfml.pluginLayerLibraries=","-Dfml.gameLayerLibraries=","-DlibraryDirectory=${library_directory}","-p","${library_directory}/cpw/mods/securejarhandler/2.1.24/securejarhandler-2.1.24.jar${classpath_separator}${library_directory}/org/ow2/asm/asm/9.8/asm-9.8.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-util/9.8/asm-util-9.8.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar${classpath_separator}${library_directory}/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar${classpath_separator}${library_directory}/net/neoforged/JarJarFileSystems/0.4.0/JarJarFileSystems-0.4.0.jar","--add-modules","ALL-MODULE-PATH","--add-opens","java.base/java.util.jar=cpw.mods.securejarhandler","--add-opens","java.base/java.lang.invoke=cpw.mods.securejarhandler","--add-exports","java.base/sun.security.util=cpw.mods.securejarhandler","--add-exports","jdk.naming.dns/com.sun.jndi.dns=java.naming"]},"libraries":[{"name":"net.neoforged.fancymodloader:earlydisplay:2.0.17@jar","downloads":{"artifact":{"sha1":"d6af13e1290e79c7e4501a097cdd1bd50531f5f3","size":262052,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/2.0.17/earlydisplay-2.0.17.jar","path":"net/neoforged/fancymodloader/earlydisplay/2.0.17/earlydisplay-2.0.17.jar"}}},{"name":"net.neoforged.fancymodloader:loader:2.0.17@jar","downloads":{"artifact":{"sha1":"e187e949dea14726924c284ef8bf6d51e4c87526","size":419246,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/2.0.17/loader-2.0.17.jar","path":"net/neoforged/fancymodloader/loader/2.0.17/loader-2.0.17.jar"}}},{"name":"net.neoforged:coremods:6.0.4@jar","downloads":{"artifact":{"sha1":"87c17499d9fd2d5c9a220ec653f0e674fb25ccaa","size":24182,"url":"https://maven.neoforged.net/releases/net/neoforged/coremods/6.0.4/coremods-6.0.4.jar","path":"net/neoforged/coremods/6.0.4/coremods-6.0.4.jar"}}},{"name":"net.neoforged.fancymodloader:spi:2.0.17@jar","downloads":{"artifact":{"sha1":"3d3f2ed71a472bb360a38eaad565cec755696406","size":31942,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/spi/2.0.17/spi-2.0.17.jar","path":"net/neoforged/fancymodloader/spi/2.0.17/spi-2.0.17.jar"}}},{"name":"cpw.mods:modlauncher:10.0.9@jar","downloads":{"artifact":{"sha1":"06d9443f56f50bb85cea383686ff9c867391458b","size":130343,"url":"https://maven.neoforged.net/releases/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar","path":"cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}},{"name":"cpw.mods:securejarhandler:2.1.24@jar","downloads":{"artifact":{"sha1":"21e16217c30688d99200796f5d828ad5b671141c","size":103854,"url":"https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.24/securejarhandler-2.1.24.jar","path":"cpw/mods/securejarhandler/2.1.24/securejarhandler-2.1.24.jar"}}},{"name":"net.neoforged.accesstransformers:at-modlauncher:10.0.1@jar","downloads":{"artifact":{"sha1":"5aba50202aceead086bc09fbc2751c9f05cb4890","size":2347,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar","path":"net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar"}}},{"name":"net.neoforged:accesstransformers:10.0.1@jar","downloads":{"artifact":{"sha1":"fd83b5725f76eae9115e9355fa1c456a6a441400","size":67234,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar","path":"net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar"}}},{"name":"org.openjdk.nashorn:nashorn-core:15.4@jar","downloads":{"artifact":{"sha1":"f67f5ffaa5f5130cf6fb9b133da00c7df3b532a5","size":2167292,"url":"https://maven.neoforged.net/releases/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar","path":"org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar"}}},{"name":"org.ow2.asm:asm-commons:9.8@jar","downloads":{"artifact":{"sha1":"36e4d212970388e5bd2c5180292012502df461bb","size":73498,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar","path":"org/ow2/asm/asm-commons/9.8/asm-commons-9.8.jar"}}},{"name":"net.neoforged:mergetool:2.0.0:api@jar","downloads":{"artifact":{"sha1":"52fe1949be64e3303aabaaa21e315f551db9c9f4","size":2550,"url":"https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar","path":"net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar"}}},{"name":"org.ow2.asm:asm-util:9.8@jar","downloads":{"artifact":{"sha1":"395f1c1f035258511f27bc9b2583d76e4b143f59","size":94559,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.8/asm-util-9.8.jar","path":"org/ow2/asm/asm-util/9.8/asm-util-9.8.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.8@jar","downloads":{"artifact":{"sha1":"b9747a320844b6cb1eacd90d8ecfd260a16c01d3","size":35151,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar","path":"org/ow2/asm/asm-analysis/9.8/asm-analysis-9.8.jar"}}},{"name":"org.ow2.asm:asm-tree:9.8@jar","downloads":{"artifact":{"sha1":"018419ca5b77a2f81097c741e7872e6ab8d2f40d","size":51934,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar","path":"org/ow2/asm/asm-tree/9.8/asm-tree-9.8.jar"}}},{"name":"net.neoforged:bus:7.2.0@jar","downloads":{"artifact":{"sha1":"03bc91f330d0e2f350ab1dbe5026a5769dc52b9a","size":35117,"url":"https://maven.neoforged.net/releases/net/neoforged/bus/7.2.0/bus-7.2.0.jar","path":"net/neoforged/bus/7.2.0/bus-7.2.0.jar"}}},{"name":"org.ow2.asm:asm:9.8@jar","downloads":{"artifact":{"sha1":"dc19ecb3f7889b7860697215cae99c0f9b6f6b4b","size":126113,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm/9.8/asm-9.8.jar","path":"org/ow2/asm/asm/9.8/asm-9.8.jar"}}},{"name":"net.minecraftforge:unsafe:0.2.0@jar","downloads":{"artifact":{"sha1":"54d7a0a5e8fdb71b973025caa46f341ae5904f39","size":2834,"url":"https://maven.neoforged.net/releases/net/minecraftforge/unsafe/0.2.0/unsafe-0.2.0.jar","path":"net/minecraftforge/unsafe/0.2.0/unsafe-0.2.0.jar"}}},{"name":"com.electronwill.night-config:toml:3.6.4@jar","downloads":{"artifact":{"sha1":"51d6cefb2b55ee55ee26b16391212fb2c7dfb4f4","size":31816,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.6.4/toml-3.6.4.jar","path":"com/electronwill/night-config/toml/3.6.4/toml-3.6.4.jar"}}},{"name":"com.electronwill.night-config:core:3.6.4@jar","downloads":{"artifact":{"sha1":"510f174abbf1c947494db50ef2445683bd52c230","size":199834,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.6.4/core-3.6.4.jar","path":"com/electronwill/night-config/core/3.6.4/core-3.6.4.jar"}}},{"name":"net.neoforged:JarJarSelector:0.4.0@jar","downloads":{"artifact":{"sha1":"122fa3fe21da1a0ace8323709197616cdb4d1416","size":17198,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.4.0/JarJarSelector-0.4.0.jar","path":"net/neoforged/JarJarSelector/0.4.0/JarJarSelector-0.4.0.jar"}}},{"name":"net.neoforged:JarJarMetadata:0.4.0@jar","downloads":{"artifact":{"sha1":"06f5e5c52382d11a15f47ca030e046519e389456","size":15676,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.4.0/JarJarMetadata-0.4.0.jar","path":"net/neoforged/JarJarMetadata/0.4.0/JarJarMetadata-0.4.0.jar"}}},{"name":"org.apache.maven:maven-artifact:3.8.5@jar","downloads":{"artifact":{"sha1":"4433f50c07debefaed0553bd0068f4f48d449313","size":58077,"url":"https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar","path":"org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar"}}},{"name":"net.jodah:typetools:0.6.3@jar","downloads":{"artifact":{"sha1":"a01aaa6ddaea9ec07ec4f209487b7a46a526283a","size":18281,"url":"https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar","path":"net/jodah/typetools/0.6.3/typetools-0.6.3.jar"}}},{"name":"net.minecrell:terminalconsoleappender:1.2.0@jar","downloads":{"artifact":{"sha1":"96d02cd3b384ff015a8fef4223bcb4ccf1717c95","size":15977,"url":"https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar","path":"net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar"}}},{"name":"net.fabricmc:sponge-mixin:0.13.4+mixin.0.8.5@jar","downloads":{"artifact":{"sha1":"8adf0cff1795a169141b9e16ad7b9f6e2527a990","size":1458874,"url":"https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.13.4+mixin.0.8.5/sponge-mixin-0.13.4+mixin.0.8.5.jar","path":"net/fabricmc/sponge-mixin/0.13.4+mixin.0.8.5/sponge-mixin-0.13.4+mixin.0.8.5.jar"}}},{"name":"org.apache.commons:commons-lang3:3.13.0@jar","downloads":{"artifact":{"sha1":"b7263237aa89c1f99b327197c41d0669707a462e","size":632267,"url":"https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar","path":"org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar"}}},{"name":"org.slf4j:slf4j-api:2.0.9@jar","downloads":{"artifact":{"sha1":"7cf2726fdcfbc8610f9a71fb3ed639871f315340","size":64579,"url":"https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar","path":"org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4@jar","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"commons-io:commons-io:2.11.0@jar","downloads":{"artifact":{"sha1":"a2503f302b11ebde7ebc3df41daebe0e4eea3689","size":327135,"url":"https://libraries.minecraft.net/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar","path":"commons-io/commons-io/2.11.0/commons-io-2.11.0.jar"}}},{"name":"org.apache.logging.log4j:log4j-core:2.19.0@jar","downloads":{"artifact":{"sha1":"3b6eeb4de4c49c0fe38a4ee27188ff5fee44d0bb","size":1864386,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar","path":"org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar"}}},{"name":"org.apache.logging.log4j:log4j-api:2.19.0@jar","downloads":{"artifact":{"sha1":"ea1b37f38c327596b216542bc636cfdc0b8036fa","size":317566,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar","path":"org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar"}}},{"name":"com.google.guava:guava:31.1-jre@jar","downloads":{"artifact":{"sha1":"60458f877d055d0c9114d9e1a2efb737b4bc282c","size":2959479,"url":"https://libraries.minecraft.net/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar","path":"com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}},{"name":"com.google.code.gson:gson:2.10@jar","downloads":{"artifact":{"sha1":"dd9b193aef96e973d5a11ab13cd17430c2e4306b","size":286235,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.10/gson-2.10.jar","path":"com/google/code/gson/gson/2.10/gson-2.10.jar"}}},{"name":"com.mojang:logging:1.1.1@jar","downloads":{"artifact":{"sha1":"832b8e6674a9b325a5175a3a6267dfaf34c85139","size":15343,"url":"https://libraries.minecraft.net/com/mojang/logging/1.1.1/logging-1.1.1.jar","path":"com/mojang/logging/1.1.1/logging-1.1.1.jar"}}},{"name":"org.jetbrains:annotations:22.0.0@jar","downloads":{"artifact":{"sha1":"fb9bebc90794c7602e73d6ed0cb165a0c650be62","size":28482,"url":"https://maven.neoforged.net/releases/org/jetbrains/annotations/22.0.0/annotations-22.0.0.jar","path":"org/jetbrains/annotations/22.0.0/annotations-22.0.0.jar"}}},{"name":"org.antlr:antlr4-runtime:4.13.1@jar","downloads":{"artifact":{"sha1":"17125bae1d965624e265ef49552f6465a2bfa307","size":326305,"url":"https://maven.neoforged.net/releases/org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar","path":"org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar"}}},{"name":"net.minecraftforge:srgutils:0.4.15@jar","downloads":{"artifact":{"sha1":"ca408b131759478f164e010fae0d73997e125fb5","size":69393,"url":"https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar","path":"net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar"}}},{"name":"org.codehaus.plexus:plexus-utils:3.3.0@jar","downloads":{"artifact":{"sha1":"cf43b5391de623b36fe066a21127baef82c64022","size":263253,"url":"https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar","path":"org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar"}}},{"name":"org.jline:jline-reader:3.12.1@jar","downloads":{"artifact":{"sha1":"4382ab1382c7b6f379377ed5f665dc2f6e1218bc","size":150765,"url":"https://maven.neoforged.net/releases/org/jline/jline-reader/3.12.1/jline-reader-3.12.1.jar","path":"org/jline/jline-reader/3.12.1/jline-reader-3.12.1.jar"}}},{"name":"com.machinezoo.noexception:noexception:1.7.1@jar","downloads":{"artifact":{"sha1":"b65330c98e38a1f915fa54a6e5eca496505e3f0a","size":250861,"url":"https://maven.neoforged.net/releases/com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar","path":"com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar"}}},{"name":"com.google.guava:failureaccess:1.0.1@jar","downloads":{"artifact":{"sha1":"1dcf1de382a0bf95a3d8b0849546c88bac1292c9","size":4617,"url":"https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar","path":"com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}},{"name":"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava@jar","downloads":{"artifact":{"sha1":"b421526c5f297295adef1c886e5246c39d4ac629","size":2199,"url":"https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar","path":"com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar"}}},{"name":"com.google.code.findbugs:jsr305:3.0.2@jar","downloads":{"artifact":{"sha1":"25ea2e8b0c338a877313bd4672d3fe056ea78f0d","size":19936,"url":"https://libraries.minecraft.net/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar","path":"com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar"}}},{"name":"org.checkerframework:checker-qual:3.12.0@jar","downloads":{"artifact":{"sha1":"d5692f0526415fcc6de94bb5bfbd3afd9dd3b3e5","size":208835,"url":"https://libraries.minecraft.net/org/checkerframework/checker-qual/3.12.0/checker-qual-3.12.0.jar","path":"org/checkerframework/checker-qual/3.12.0/checker-qual-3.12.0.jar"}}},{"name":"com.google.errorprone:error_prone_annotations:2.11.0@jar","downloads":{"artifact":{"sha1":"c5a0ace696d3f8b1c1d8cc036d8c03cc0cbe6b69","size":15979,"url":"https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.11.0/error_prone_annotations-2.11.0.jar","path":"com/google/errorprone/error_prone_annotations/2.11.0/error_prone_annotations-2.11.0.jar"}}},{"name":"com.google.j2objc:j2objc-annotations:1.3@jar","downloads":{"artifact":{"sha1":"ba035118bc8bac37d7eff77700720999acd9986d","size":8781,"url":"https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar","path":"com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar"}}},{"name":"org.jline:jline-terminal:3.12.1@jar","downloads":{"artifact":{"sha1":"c777448314e050d980a6b697c140f3bfe9eb7416","size":211712,"url":"https://maven.neoforged.net/releases/org/jline/jline-terminal/3.12.1/jline-terminal-3.12.1.jar","path":"org/jline/jline-terminal/3.12.1/jline-terminal-3.12.1.jar"}}},{"name":"cpw.mods:bootstraplauncher:1.1.2@jar","downloads":{"artifact":{"sha1":"0c546e00443d8432cda6baa1c860346980742628","size":8284,"url":"https://maven.neoforged.net/releases/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar","path":"cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"}}},{"name":"net.neoforged:JarJarFileSystems:0.4.0@jar","downloads":{"artifact":{"sha1":"ef7e5716525bbe50c784a362f9393457a33e6daf","size":31835,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarFileSystems/0.4.0/JarJarFileSystems-0.4.0.jar","path":"net/neoforged/JarJarFileSystems/0.4.0/JarJarFileSystems-0.4.0.jar"}}}]} \ No newline at end of file diff --git a/src/test/resources/native-launcher/quilt-1.20.4.json b/src/test/resources/native-launcher/quilt-1.20.4.json new file mode 100644 index 00000000..565b4b05 --- /dev/null +++ b/src/test/resources/native-launcher/quilt-1.20.4.json @@ -0,0 +1,69 @@ +{ + "id": "quilt-loader-0.24.0-1.20.4", + "inheritsFrom": "1.20.4", + "type": "release", + "mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient", + "arguments": { + "game": [] + }, + "libraries": [ + { + "name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:sponge-mixin:0.12.5+mixin.0.8.5", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:tiny-remapper:0.10.1", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:access-widener:2.1.0", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.quiltmc:quilt-json5:1.0.4+final", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.ow2.asm:asm:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-analysis:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-commons:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-tree:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-util:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.quiltmc:quilt-config:1.2.0", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.quiltmc:quilt-loader:0.24.0", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.quiltmc:hashed:1.20.4", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "net.fabricmc:intermediary:1.20.4", + "url": "https://maven.fabricmc.net/" + } + ], + "releaseTime": "2023-12-07T12:56:20+00:00", + "time": "2026-09-15T06:28:05+00:00" +} \ No newline at end of file diff --git a/src/test/resources/native-launcher/sources.json b/src/test/resources/native-launcher/sources.json new file mode 100644 index 00000000..c0c9aee4 --- /dev/null +++ b/src/test/resources/native-launcher/sources.json @@ -0,0 +1,86 @@ +{ + "retrievedUtc": "2026-10-03T20:03:48.378Z", + "sources": [ + { + "file": "1.20.4.json", + "url": "https://piston-meta.mojang.com/v1/packages/bff1278922bca138b7722385a81366f3077abffa/1.20.4.json", + "mojangSha1": "bff1278922bca138b7722385a81366f3077abffa" + }, + { + "file": "1.12.2.json", + "url": "https://piston-meta.mojang.com/v1/packages/832d95b9f40699d4961394dcf6cf549e65f15dc5/1.12.2.json", + "mojangSha1": "832d95b9f40699d4961394dcf6cf549e65f15dc5" + }, + { + "file": "1.20.5.json", + "url": "https://piston-meta.mojang.com/v1/packages/2b95c082b76616fcf6e2aa9fe443a45c15ccb435/1.20.5.json", + "mojangSha1": "2b95c082b76616fcf6e2aa9fe443a45c15ccb435" + }, + { + "file": "fabric-1.20.4.json", + "url": "https://meta.fabricmc.net/v2/versions/loader/1.20.4/0.15.11/profile/json" + }, + { + "file": "quilt-1.20.4.json", + "url": "https://meta.quiltmc.org/v3/versions/loader/1.20.4/0.24.0/profile/json" + }, + { + "file": "1.19.4.json", + "url": "https://piston-meta.mojang.com/v1/packages/b67b917c119918ddd3aa43ad98eed089a06dbc97/1.19.4.json", + "mojangSha1": "b67b917c119918ddd3aa43ad98eed089a06dbc97" + }, + { + "file": "forge-install_profile.json", + "sourceArchive": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-installer.jar", + "entry": "install_profile.json" + }, + { + "file": "forge-version.json", + "sourceArchive": "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.2.0/forge-1.20.4-49.2.0-installer.jar", + "entry": "version.json" + }, + { + "file": "neoforge-install_profile.json", + "sourceArchive": "https://maven.neoforged.net/releases/net/neoforged/neoforge/20.4.251/neoforge-20.4.251-installer.jar", + "entry": "install_profile.json" + }, + { + "file": "neoforge-version.json", + "sourceArchive": "https://maven.neoforged.net/releases/net/neoforged/neoforge/20.4.251/neoforge-20.4.251-installer.jar", + "entry": "version.json" + }, + { + "file": "26.3.json", + "url": "https://piston-meta.mojang.com/v1/packages/4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3/26.3.json", + "mojangSha1": "4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3" + }, + { + "file": "forge-26.3-install_profile.json", + "sourceArchive": "https://maven.minecraftforge.net/net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-installer.jar", + "archiveSha1": "b1d66c82fa7b7caa612a70259b24ec95a41a023f", + "entry": "install_profile.json", + "recordedUtc": "2026-10-03T20:42:15.100Z" + }, + { + "file": "forge-26.3-version.json", + "sourceArchive": "https://maven.minecraftforge.net/net/minecraftforge/forge/26.3-66.0.9/forge-26.3-66.0.9-installer.jar", + "archiveSha1": "b1d66c82fa7b7caa612a70259b24ec95a41a023f", + "entry": "version.json", + "recordedUtc": "2026-10-03T20:42:15.100Z" + }, + { + "file": "neoforge-26.3-install_profile.json", + "sourceArchive": "https://maven.neoforged.net/releases/net/neoforged/neoforge/26.3.0.46-beta/neoforge-26.3.0.46-beta-installer.jar", + "archiveSha1": "3807c85c92f4f2c91132494a02621f24dfa71a39", + "entry": "install_profile.json", + "recordedUtc": "2026-10-03T20:42:15.100Z" + }, + { + "file": "neoforge-26.3-version.json", + "sourceArchive": "https://maven.neoforged.net/releases/net/neoforged/neoforge/26.3.0.46-beta/neoforge-26.3.0.46-beta-installer.jar", + "archiveSha1": "3807c85c92f4f2c91132494a02621f24dfa71a39", + "entry": "version.json", + "recordedUtc": "2026-10-03T20:42:15.100Z" + } + ] +} diff --git a/src/test/resources/virtual-worlds/fabric-1.20.4-loaders.json b/src/test/resources/virtual-worlds/fabric-1.20.4-loaders.json new file mode 100644 index 00000000..b723ff64 --- /dev/null +++ b/src/test/resources/virtual-worlds/fabric-1.20.4-loaders.json @@ -0,0 +1,72 @@ +[ + { + "loader": { + "separator": ".", + "build": 5, + "maven": "net.fabricmc:fabric-loader:0.19.5", + "version": "0.19.5", + "stable": true + }, + "intermediary": { + "maven": "net.fabricmc:intermediary:1.20.4", + "version": "1.20.4", + "stable": true + } + }, + { + "loader": { + "separator": ".", + "build": 4, + "maven": "net.fabricmc:fabric-loader:0.19.4", + "version": "0.19.4", + "stable": false + }, + "intermediary": { + "maven": "net.fabricmc:intermediary:1.20.4", + "version": "1.20.4", + "stable": true + } + }, + { + "loader": { + "separator": ".", + "build": 3, + "maven": "net.fabricmc:fabric-loader:0.19.3", + "version": "0.19.3", + "stable": false + }, + "intermediary": { + "maven": "net.fabricmc:intermediary:1.20.4", + "version": "1.20.4", + "stable": true + } + }, + { + "loader": { + "separator": ".", + "build": 2, + "maven": "net.fabricmc:fabric-loader:0.19.2", + "version": "0.19.2", + "stable": false + }, + "intermediary": { + "maven": "net.fabricmc:intermediary:1.20.4", + "version": "1.20.4", + "stable": true + } + }, + { + "loader": { + "separator": ".", + "build": 1, + "maven": "net.fabricmc:fabric-loader:0.19.1", + "version": "0.19.1", + "stable": false + }, + "intermediary": { + "maven": "net.fabricmc:intermediary:1.20.4", + "version": "1.20.4", + "stable": true + } + } +] \ No newline at end of file diff --git a/src/test/resources/virtual-worlds/fabric-installers.json b/src/test/resources/virtual-worlds/fabric-installers.json new file mode 100644 index 00000000..08b7d132 --- /dev/null +++ b/src/test/resources/virtual-worlds/fabric-installers.json @@ -0,0 +1,32 @@ +[ + { + "url": "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.2/fabric-installer-1.1.2.jar", + "maven": "net.fabricmc:fabric-installer:1.1.2", + "version": "1.1.2", + "stable": true + }, + { + "url": "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/fabric-installer-1.1.1.jar", + "maven": "net.fabricmc:fabric-installer:1.1.1", + "version": "1.1.1", + "stable": false + }, + { + "url": "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.0/fabric-installer-1.1.0.jar", + "maven": "net.fabricmc:fabric-installer:1.1.0", + "version": "1.1.0", + "stable": false + }, + { + "url": "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.0.3/fabric-installer-1.0.3.jar", + "maven": "net.fabricmc:fabric-installer:1.0.3", + "version": "1.0.3", + "stable": false + }, + { + "url": "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.0.2/fabric-installer-1.0.2.jar", + "maven": "net.fabricmc:fabric-installer:1.0.2", + "version": "1.0.2", + "stable": false + } +] \ No newline at end of file diff --git a/src/test/resources/virtual-worlds/paper-1.20.4-builds.json b/src/test/resources/virtual-worlds/paper-1.20.4-builds.json new file mode 100644 index 00000000..3d833370 --- /dev/null +++ b/src/test/resources/virtual-worlds/paper-1.20.4-builds.json @@ -0,0 +1,77 @@ +[ + { + "id": 499, + "time": "2024-10-30T19:22:05.407Z", + "channel": "STABLE", + "downloads": { + "server:default": { + "name": "paper-1.20.4-499.jar", + "checksums": { + "sha256": "cabed3ae77cf55deba7c7d8722bc9cfd5e991201c211665f9265616d9fe5c77b" + }, + "size": 42781488, + "url": "https://fill-data.papermc.io/v1/objects/cabed3ae77cf55deba7c7d8722bc9cfd5e991201c211665f9265616d9fe5c77b/paper-1.20.4-499.jar" + } + } + }, + { + "id": 497, + "time": "2024-05-28T10:22:46.458Z", + "channel": "STABLE", + "downloads": { + "server:default": { + "name": "paper-1.20.4-497.jar", + "checksums": { + "sha256": "78209e108182b893ced65daa1fc593b45d3115900f0498d3e8152a7d73d38392" + }, + "size": 42780456, + "url": "https://fill-data.papermc.io/v1/objects/78209e108182b893ced65daa1fc593b45d3115900f0498d3e8152a7d73d38392/paper-1.20.4-497.jar" + } + } + }, + { + "id": 496, + "time": "2024-04-24T16:54:08.312Z", + "channel": "STABLE", + "downloads": { + "server:default": { + "name": "paper-1.20.4-496.jar", + "checksums": { + "sha256": "9c6419c3504db84d437d4bbeb5b9d27d548a009a3ed2f1c617b62e7341cfeee3" + }, + "size": 42779942, + "url": "https://fill-data.papermc.io/v1/objects/9c6419c3504db84d437d4bbeb5b9d27d548a009a3ed2f1c617b62e7341cfeee3/paper-1.20.4-496.jar" + } + } + }, + { + "id": 493, + "time": "2024-04-20T20:08:53.546Z", + "channel": "STABLE", + "downloads": { + "server:default": { + "name": "paper-1.20.4-493.jar", + "checksums": { + "sha256": "aa3bebd029b2ac692fe8947c6dae0c25084e991ea93fd64784375384a6e40b85" + }, + "size": 42780617, + "url": "https://fill-data.papermc.io/v1/objects/aa3bebd029b2ac692fe8947c6dae0c25084e991ea93fd64784375384a6e40b85/paper-1.20.4-493.jar" + } + } + }, + { + "id": 492, + "time": "2024-04-20T19:35:07.264Z", + "channel": "STABLE", + "downloads": { + "server:default": { + "name": "paper-1.20.4-492.jar", + "checksums": { + "sha256": "dfeb14c80888b77698c9afaf406c5dec9de33cb1792b24952d67ee1de572cdd6" + }, + "size": 42777641, + "url": "https://fill-data.papermc.io/v1/objects/dfeb14c80888b77698c9afaf406c5dec9de33cb1792b24952d67ee1de572cdd6/paper-1.20.4-492.jar" + } + } + } +] \ No newline at end of file diff --git a/src/test/resources/virtual-worlds/purpur-1.20.4-latest.json b/src/test/resources/virtual-worlds/purpur-1.20.4-latest.json new file mode 100644 index 00000000..4d3db207 --- /dev/null +++ b/src/test/resources/virtual-worlds/purpur-1.20.4-latest.json @@ -0,0 +1,8 @@ +{ + "project": "purpur", + "version": "1.20.4", + "build": "2176", + "result": "SUCCESS", + "timestamp": 1713939818972, + "md5": "c753922385b06885306c0b36a689c97f" +} \ No newline at end of file diff --git a/src/test/resources/virtual-worlds/sources.json b/src/test/resources/virtual-worlds/sources.json new file mode 100644 index 00000000..eccbf296 --- /dev/null +++ b/src/test/resources/virtual-worlds/sources.json @@ -0,0 +1,42 @@ +[ + { + "file": "paper-1.20.4-builds.json", + "url": "https://fill.papermc.io/v3/projects/paper/versions/1.20.4/builds", + "status": 200, + "observedUtc": "2026-10-03T21:22:05.6850678Z", + "bytes": 181172, + "capturedResponseSha256": "dfcf3fdb881e12a60e10e3d3c88201bb932b3969e48c0936cd186c2af6b2f964", + "fixtureSha256": "1268d733cc956fcb78fe3cdb49f0a3dd7071601597d851e85eb4031308fccc6d", + "transformation": "First five response records; only fields used by server artifact selection retained. Purpur retains its single build record. Values come directly from the successful official GET." + }, + { + "file": "purpur-1.20.4-latest.json", + "url": "https://api.purpurmc.org/v2/purpur/1.20.4/latest", + "status": 200, + "observedUtc": "2026-10-03T21:22:06.0806136Z", + "bytes": 359, + "capturedResponseSha256": "3b7de99b8a9268c95bb5cd4219c88a6ec81ff7983755606e26ee91204050fd37", + "fixtureSha256": "8c5a54c9e4ccf9af82b8e718cec63d0d9cbfa750bc250695d1b02cb4f885e9c5", + "transformation": "First five response records; only fields used by server artifact selection retained. Purpur retains its single build record. Values come directly from the successful official GET." + }, + { + "file": "fabric-1.20.4-loaders.json", + "url": "https://meta.fabricmc.net/v2/versions/loader/1.20.4", + "status": 200, + "observedUtc": "2026-10-03T21:22:06.6015498Z", + "bytes": 590278, + "capturedResponseSha256": "e672569c405c1343110c8d9130062d75a1fb9d853dc97b7835cc092e9b901560", + "fixtureSha256": "51f6b4a409ea386a466798ffcd38c564efb690279faa6fa131b867ef7372ecd0", + "transformation": "First five response records; only fields used by server artifact selection retained. Purpur retains its single build record. Values come directly from the successful official GET." + }, + { + "file": "fabric-installers.json", + "url": "https://meta.fabricmc.net/v2/versions/installer", + "status": 200, + "observedUtc": "2026-10-03T21:22:06.8889492Z", + "bytes": 14544, + "capturedResponseSha256": "55918208b3acb6ea205b662e57551a017fca51606ed2f3fcd71b71cd0f3d77a2", + "fixtureSha256": "d7259fab803d2769b7f841563d0eaeb373e3e9d4c0dffbb1abf33d9045215f2b", + "transformation": "First five response records; only fields used by server artifact selection retained. Purpur retains its single build record. Values come directly from the successful official GET." + } +] \ No newline at end of file