Форк клиента собирается из исходников, модзагрузчик встроен в игру
Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - prepare-game.ps1 пересобирает клиент целиком: ванильный jar, SRG и MCP, SpecialSource, FernFlower, патчи, автофиксеры, javac. Идемпотентен. - Свой код игры лежит в src-overlay, потому что src пересоздаётся при каждой сборке и всё написанное вручную там погибает. - 116 патчей в patches/decompiler-fixes.txt. Каждый сверялся с оригинальным байткодом через javap: компиляция не ловит ошибки декомпиляции. - struct_diff.py сравнивает исходный байткод с нашей сборкой по классам, интерфейсам, суперклассам, методам и полям, и отдельно проверяет, что пропавшие классы действительно никому не нужны. Загрузчик - Собирается и тестируется без игры: build.ps1 не использует classpath вообще, поэтому SelfTest из 128 проверок проходит без запущенного форка. - Клиентский API не содержит ни одного игрового типа, реализацию даёт игра. - Политика: мод меняет то, что клиент показывает, но никогда то, что истинно в мире. Мир, предметы, игроки и сеть принадлежат серверу. - Capability приведены в соответствие с этой политикой: осталось шесть, и за каждым стоит реальная проверка. Прежние WORLD, PLAYER, NETWORK, TEXT, INPUT и COMMAND выдавались, но ничего не проверяли. - Команды модов имеют область CLIENT, SERVER или BOTH. Серверные не выполняются на клиенте никогда. - Свой jar загрузчика лежит в classpath игры и сборки форка. Инструменты - add_patch.py, чтобы каждое исправление переживало передекомпиляцию - find_self_comparisons.py, find_remove_index_bugs.py, find_callers.py - stage_run.py и fetch_missing.py для запуска клиента
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package net.rustnotmc.api;
|
||||
|
||||
import net.rustnotmc.api.client.ClientCommand;
|
||||
import net.rustnotmc.api.client.ClientHost;
|
||||
import net.rustnotmc.api.client.ClientServices;
|
||||
import net.rustnotmc.api.client.CommandScope;
|
||||
import net.rustnotmc.api.hud.Anchor;
|
||||
import net.rustnotmc.api.hud.HudLayout;
|
||||
|
||||
import net.rustnotmc.loader.Access;
|
||||
import net.rustnotmc.loader.EventBus;
|
||||
import net.rustnotmc.loader.GameAccess;
|
||||
import net.rustnotmc.loader.Log;
|
||||
import net.rustnotmc.loader.ModContext;
|
||||
import net.rustnotmc.loader.ModDescriptor;
|
||||
import net.rustnotmc.loader.TickScheduler;
|
||||
import net.rustnotmc.loader.Version;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a mod is handed in {@link RustNotMcMod#onConstruct}.
|
||||
*
|
||||
* Everything a mod can reach goes through here, so it is the one place a
|
||||
* capability can be enforced. Nothing in this class exposes a raw game object
|
||||
* unless the mod holds the capability for it.
|
||||
*/
|
||||
public final class Rsx {
|
||||
|
||||
private final ModContext ctx;
|
||||
|
||||
public Rsx(ModContext ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- identity
|
||||
|
||||
public String modId() {
|
||||
return ctx.modId();
|
||||
}
|
||||
|
||||
public String modName() {
|
||||
return ctx.name();
|
||||
}
|
||||
|
||||
public Version modVersion() {
|
||||
return ctx.version();
|
||||
}
|
||||
|
||||
/** The API version the loader speaks. */
|
||||
public int apiVersion() {
|
||||
return ModDescriptor.API_VERSION;
|
||||
}
|
||||
|
||||
/** True on the client. Always true while the loader only ships on the client. */
|
||||
public boolean isClient() {
|
||||
return ctx.isClient();
|
||||
}
|
||||
|
||||
public boolean isServer() {
|
||||
return ctx.isServer();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ permissions
|
||||
|
||||
public boolean has(Access capability) {
|
||||
return ctx.has(capability);
|
||||
}
|
||||
|
||||
public Set<Access> capabilities() {
|
||||
return ctx.access();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- access
|
||||
|
||||
/**
|
||||
* The game, subject to this mod's capabilities.
|
||||
*
|
||||
* Handles are cached, so repeated reads of the same member are cheap. Guard
|
||||
* a capability yourself if the read is expensive to fail.
|
||||
*/
|
||||
public GameAccess game() {
|
||||
return ctx.game();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- threading
|
||||
|
||||
/** Run on the game thread, next tick. */
|
||||
public TickScheduler.Task post(Runnable body) {
|
||||
return ctx.scheduler().post(ctx.modId(), body);
|
||||
}
|
||||
|
||||
/** Run every N game ticks. */
|
||||
public TickScheduler.Task everyTicks(int n, Runnable body) {
|
||||
return ctx.scheduler().everyTicks(ctx.modId(), n, body);
|
||||
}
|
||||
|
||||
/** Run on a wall-clock period; the body still executes on the game thread. */
|
||||
public TickScheduler.Task every(long millis, Runnable body) {
|
||||
return ctx.scheduler().every(ctx.modId(), millis, body);
|
||||
}
|
||||
|
||||
/** Current game tick. */
|
||||
public long tick() {
|
||||
return ctx.scheduler().tickCount();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- events
|
||||
|
||||
public EventBus events() {
|
||||
return ctx.events();
|
||||
}
|
||||
|
||||
public <T> EventBus.Subscription on(Class<T> type, java.util.function.Consumer<T> body) {
|
||||
return ctx.events().subscribe(ctx.modId(), type, body);
|
||||
}
|
||||
|
||||
public <T> EventBus.Subscription on(Class<T> type, int priority,
|
||||
java.util.function.Consumer<T> body) {
|
||||
return ctx.events().subscribe(ctx.modId(), type, priority, body);
|
||||
}
|
||||
|
||||
public <T> T post(T event) {
|
||||
return ctx.events().post(event);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- hud
|
||||
|
||||
/**
|
||||
* Registers an overlay on the game interface.
|
||||
*
|
||||
* Needs the HUD capability. Returns null when the mod did not declare it,
|
||||
* rather than throwing, so a missing declaration cannot take a mod down.
|
||||
*/
|
||||
public HudLayout.Slot hud(String name, HudLayout.Draw draw, Anchor anchor,
|
||||
int layer, int offsetX, int offsetY) {
|
||||
if (!ctx.has(Access.HUD)) {
|
||||
ctx.log().warn("asked to draw an overlay without the 'hud' capability");
|
||||
return null;
|
||||
}
|
||||
return HudLayout.add(name, draw, anchor, layer, offsetX, offsetY);
|
||||
}
|
||||
|
||||
/** Registers an overlay at the normal layer, flush with its corner. */
|
||||
public HudLayout.Slot hud(String name, HudLayout.Draw draw, Anchor anchor) {
|
||||
return hud(name, draw, anchor, HudLayout.NORMAL, 0, 0);
|
||||
}
|
||||
|
||||
/** Drops every overlay this mod registered. */
|
||||
public void clearHud() {
|
||||
HudLayout.remove(ctx.modId());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- client
|
||||
|
||||
/**
|
||||
* The client this mod is running in: screens, input and commands.
|
||||
*
|
||||
* This is the whole client surface, and it stops at what the client shows.
|
||||
* There is no route from here to a block, an item, a player or a packet,
|
||||
* because the server owns those.
|
||||
*
|
||||
* Outside a client, for instance in the loader self test, this returns a
|
||||
* detached stand-in that answers with zeros rather than throwing.
|
||||
*/
|
||||
public ClientServices client() {
|
||||
return ClientHost.current();
|
||||
}
|
||||
|
||||
/** True when a real client is attached, false in a detached host. */
|
||||
public boolean hasClient() {
|
||||
return ClientHost.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard and mouse state, needs the INPUT capability.
|
||||
*
|
||||
* The same view is also reachable through {@link #client()}; this is the
|
||||
* checked door, and it returns a stand-in that reports nothing rather than
|
||||
* throwing, so a missing declaration cannot take a mod down.
|
||||
*/
|
||||
public net.rustnotmc.api.client.Input input() {
|
||||
if (!ctx.has(Access.INPUT)) {
|
||||
return net.rustnotmc.api.client.ClientHost.deniedInput();
|
||||
}
|
||||
return client().input();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a mod command the player can type.
|
||||
*
|
||||
* Needs the COMMANDS capability. The scope decides who carries it out: a
|
||||
* CLIENT command runs here and affects only the client, while SERVER and
|
||||
* BOTH are the server's to decide and are not executed locally.
|
||||
*/
|
||||
public boolean command(String name, ClientCommand handler, CommandScope scope) {
|
||||
if (!ctx.has(Access.COMMANDS)) {
|
||||
ctx.log().warn("asked to register a command without the 'commands' capability");
|
||||
return false;
|
||||
}
|
||||
return client().commands().register(name, handler, scope);
|
||||
}
|
||||
|
||||
/** Registers a command that only ever affects the client. */
|
||||
public boolean command(String name, ClientCommand handler) {
|
||||
return command(name, handler, CommandScope.CLIENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* This mod's own directory under the mods folder, created on first use.
|
||||
*
|
||||
* Needs the FILES capability. It is the only place a mod may write: the
|
||||
* game directory holds save data, which belongs to the server, and every
|
||||
* other mod's directory is off limits.
|
||||
*/
|
||||
public java.io.File modDirectory() {
|
||||
if (!ctx.has(Access.FILES)) {
|
||||
ctx.log().warn("asked for its directory without the 'files' capability");
|
||||
return null;
|
||||
}
|
||||
return ctx.modDirectory();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- logs
|
||||
|
||||
/** Log with this mod's id already attached. */
|
||||
public Log.ModLog log() {
|
||||
return ctx.log();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package net.rustnotmc.api;
|
||||
|
||||
/**
|
||||
* What a mod implements.
|
||||
*
|
||||
* Every method has a default, so a mod overrides only what it needs. The order is
|
||||
* always: construct, load, enable, disable, unload. A mod may rely on its own
|
||||
* dependencies being fully enabled by the time onEnable runs.
|
||||
*/
|
||||
public interface RustNotMcMod {
|
||||
|
||||
/** The loader API version this mod was written against. */
|
||||
default int apiVersion() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once, right after construction, with everything the mod is allowed
|
||||
* to do. Registering here rather than in onEnable is what makes a later
|
||||
* disable/enable cycle safe.
|
||||
*/
|
||||
default void onConstruct(Rsx context) {
|
||||
}
|
||||
|
||||
/** Called once before anything is enabled. */
|
||||
default void onLoad() {
|
||||
}
|
||||
|
||||
/** Called when the mod starts doing work. */
|
||||
default void onEnable() {
|
||||
}
|
||||
|
||||
/** Called before the mod stops. Release resources here. */
|
||||
default void onDisable() {
|
||||
}
|
||||
|
||||
/** Called last, when the mod is going away for good. */
|
||||
default void onUnload() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when one of the mod's own tasks or handlers throws.
|
||||
*
|
||||
* The loader has already logged the throwable, and has already dropped the
|
||||
* task after five consecutive failures. This is the mod's chance to react.
|
||||
*/
|
||||
default void onError(Throwable error) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* A command a mod adds.
|
||||
*
|
||||
* The handler runs in the client only when its scope is CLIENT. For SERVER and
|
||||
* BOTH it is not called at all: the line is passed on and the server decides.
|
||||
*/
|
||||
public interface ClientCommand {
|
||||
|
||||
/** Runs the command locally. Not called for server-decided scopes. */
|
||||
void run(CommandArgs args);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Where the game hands over its client implementation.
|
||||
*
|
||||
* The API interfaces are written without a single game type so the loader keeps
|
||||
* building and testing with no game present, which is what lets the self test
|
||||
* run headless. The game supplies the implementation once, at startup, and mods
|
||||
* read it through {@link net.rustnotmc.api.Rsx#client()}.
|
||||
*/
|
||||
public final class ClientHost {
|
||||
|
||||
private static final Map<String, CommandScope> SCOPES =
|
||||
new LinkedHashMap<String, CommandScope>();
|
||||
|
||||
private static volatile ClientServices services;
|
||||
|
||||
private ClientHost() {
|
||||
}
|
||||
|
||||
/** Called by the game once, when the client is ready. */
|
||||
public static void install(ClientServices implementation) {
|
||||
services = implementation;
|
||||
}
|
||||
|
||||
/** Called by the game when the client is shutting down. */
|
||||
public static void remove() {
|
||||
services = null;
|
||||
synchronized (SCOPES) {
|
||||
SCOPES.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** True while a client is attached. */
|
||||
public static boolean present() {
|
||||
return services != null;
|
||||
}
|
||||
|
||||
/** The client, or a detached stand-in that says so. */
|
||||
public static ClientServices current() {
|
||||
ClientServices s = services;
|
||||
return s == null ? Detached.INSTANCE : s;
|
||||
}
|
||||
|
||||
static void register(String owner, String name, CommandScope scope) {
|
||||
synchronized (SCOPES) {
|
||||
SCOPES.put(name, scope);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isRegistered(String name) {
|
||||
synchronized (SCOPES) {
|
||||
return SCOPES.containsKey(name);
|
||||
}
|
||||
}
|
||||
|
||||
static CommandScope scopeOf(String name) {
|
||||
synchronized (SCOPES) {
|
||||
return SCOPES.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
static int commandCount() {
|
||||
synchronized (SCOPES) {
|
||||
return SCOPES.size();
|
||||
}
|
||||
}
|
||||
|
||||
/** Input as a mod without the INPUT capability is allowed to see. */
|
||||
public static net.rustnotmc.api.client.Input deniedInput() {
|
||||
return Detached.INPUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a mod sees when the loader is running outside a client, which is the
|
||||
* self test and any server-side host.
|
||||
*
|
||||
* It answers honestly instead of throwing, so a mod written against the
|
||||
* client API can still be loaded and exercised with no game running.
|
||||
*/
|
||||
private static final class Detached implements ClientServices {
|
||||
|
||||
static final Detached INSTANCE = new Detached();
|
||||
|
||||
private static final Screen SCREEN = new Screen() {
|
||||
public ScreenKind kind() {
|
||||
return ScreenKind.UNKNOWN;
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public float scale() {
|
||||
return 1f;
|
||||
}
|
||||
|
||||
public int windowWidth() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int windowHeight() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
private static final Input INPUT = new Input() {
|
||||
public boolean keyDown(String keyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean anyKeyDown() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean focused() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public int mouseX() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int mouseY() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int mouseButtons() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public boolean mouseDown(int button) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
private static final Commands COMMANDS = new Commands() {
|
||||
public boolean register(String name, ClientCommand handler, CommandScope scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean register(String name, ClientCommand handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isRegistered(String name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public CommandScope scopeOf(String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public int count() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
public Screen screen() {
|
||||
return SCREEN;
|
||||
}
|
||||
|
||||
public Input input() {
|
||||
return INPUT;
|
||||
}
|
||||
|
||||
public Commands commands() {
|
||||
return COMMANDS;
|
||||
}
|
||||
|
||||
public long tick() {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
public long frame() {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Names currently registered, for logging and diagnostics. */
|
||||
public static Map<String, CommandScope> registered() {
|
||||
synchronized (SCOPES) {
|
||||
return Collections.unmodifiableMap(
|
||||
new LinkedHashMap<String, CommandScope>(SCOPES));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* The client the mod is running in.
|
||||
*
|
||||
* This is the whole surface a client mod gets, and it is deliberately small: it
|
||||
* describes what the client shows, what the keyboard and mouse are doing, and
|
||||
* where to add commands. There is no path from here to a block, an item, a
|
||||
* player or a packet, because the server owns those and a client mod is not
|
||||
* allowed to decide them.
|
||||
*
|
||||
* A mod that needs something genuinely outside this boundary is expected to say
|
||||
* so, and reach for the reflective escape hatch deliberately, with the operator's
|
||||
* consent, rather than finding a back door.
|
||||
*/
|
||||
public interface ClientServices {
|
||||
|
||||
/** The display state, never null. */
|
||||
Screen screen();
|
||||
|
||||
/** Keyboard and mouse state, never null. */
|
||||
Input input();
|
||||
|
||||
/** The mod command table, never null. */
|
||||
Commands commands();
|
||||
|
||||
/** Ticks since the client started, driven by the game loop. */
|
||||
long tick();
|
||||
|
||||
/** Frames drawn, which at a steady frame rate is a better clock than tick. */
|
||||
long frame();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* What a player typed when they ran a mod command.
|
||||
*
|
||||
* The whole line is kept as well as the split arguments, because a mod usually
|
||||
* wants one or the other and re-parsing the original is where quoting bugs come
|
||||
* from.
|
||||
*/
|
||||
public final class CommandArgs {
|
||||
|
||||
private final String name;
|
||||
private final String raw;
|
||||
private final String[] args;
|
||||
private final String sender;
|
||||
|
||||
public CommandArgs(String name, String raw, String[] args, String sender) {
|
||||
this.name = name;
|
||||
this.raw = raw;
|
||||
this.args = args == null ? new String[0] : args.clone();
|
||||
this.sender = sender == null ? "local" : sender;
|
||||
}
|
||||
|
||||
/** Splits "/rsx hello world" into the name "rsx" and two arguments. */
|
||||
public static CommandArgs parse(String line, String sender) {
|
||||
String text = line == null ? "" : line.trim();
|
||||
if (text.startsWith("/")) {
|
||||
text = text.substring(1);
|
||||
}
|
||||
String[] parts = text.split("\\s+");
|
||||
String name = parts.length == 0 ? "" : parts[0];
|
||||
String[] rest = parts.length <= 1 ? new String[0] : Arrays.copyOfRange(parts, 1, parts.length);
|
||||
return new CommandArgs(name, text, rest, sender);
|
||||
}
|
||||
|
||||
/** The command name, without the leading slash. */
|
||||
public String name() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/** The line as typed, without the leading slash. */
|
||||
public String raw() {
|
||||
return this.raw;
|
||||
}
|
||||
|
||||
/** A copy of the arguments after the name. */
|
||||
public String[] args() {
|
||||
return this.args.clone();
|
||||
}
|
||||
|
||||
public int count() {
|
||||
return this.args.length;
|
||||
}
|
||||
|
||||
/** Argument at the index, or the fallback when it is absent. */
|
||||
public String arg(int index, String fallback) {
|
||||
return index >= 0 && index < this.args.length ? this.args[index] : fallback;
|
||||
}
|
||||
|
||||
/** Argument at the index parsed as an integer, or the fallback. */
|
||||
public int argInt(int index, int fallback) {
|
||||
String v = arg(index, null);
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who ran it, as the client knows it.
|
||||
*
|
||||
* This is the local label the client shows, nothing more: it is not a player
|
||||
* identity and carries no authority.
|
||||
*/
|
||||
public String sender() {
|
||||
return this.sender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "/" + this.name + (this.args.length == 0 ? "" : " " + join());
|
||||
}
|
||||
|
||||
private String join() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < this.args.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(this.args[i]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* Where a mod command actually runs.
|
||||
*
|
||||
* The scope is not decoration. The server owns the world, so a command that can
|
||||
* change the world has to be the server's to decide, and the client half of one
|
||||
* is only a request.
|
||||
*/
|
||||
public enum CommandScope {
|
||||
|
||||
/**
|
||||
* Runs in the client and only ever affects the client.
|
||||
*
|
||||
* Opening a settings screen, toggling an overlay, printing a message. The
|
||||
* effect is local and nothing is sent anywhere.
|
||||
*/
|
||||
CLIENT,
|
||||
|
||||
/**
|
||||
* Runs on the server.
|
||||
*
|
||||
* A client mod may declare that the server has such a command, but the
|
||||
* client will not execute it. Only the server, and only for a player the
|
||||
* server considers allowed, decides what it does.
|
||||
*/
|
||||
SERVER,
|
||||
|
||||
/**
|
||||
* Registered on both sides.
|
||||
*
|
||||
* The client half accepts the line and sends it on; the server decides
|
||||
* whether it runs and what it does. Until the server implementation exists
|
||||
* the client half says so rather than pretending to work.
|
||||
*/
|
||||
BOTH;
|
||||
|
||||
/** True when the client itself may carry out the command locally. */
|
||||
public boolean clientExecutes() {
|
||||
return this == CLIENT;
|
||||
}
|
||||
|
||||
/** True when the server has the final say. */
|
||||
public boolean serverDecides() {
|
||||
return this == SERVER || this == BOTH;
|
||||
}
|
||||
|
||||
public static CommandScope byName(String raw, CommandScope fallback) {
|
||||
if (raw != null) {
|
||||
for (CommandScope s : values()) {
|
||||
if (s.name().equalsIgnoreCase(raw.trim())) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* The mod command table.
|
||||
*
|
||||
* Registration happens in onConstruct, so a disable and enable cycle does not
|
||||
* lose or duplicate commands. Names are unique across mods: a second mod asking
|
||||
* for a name already taken is refused and told who has it, rather than silently
|
||||
* shadowing the first.
|
||||
*/
|
||||
public interface Commands {
|
||||
|
||||
/**
|
||||
* Registers a command the player can type with a leading slash.
|
||||
*
|
||||
* @return false when the name is taken, already registered by this mod, or
|
||||
* otherwise unusable.
|
||||
*/
|
||||
boolean register(String name, ClientCommand handler, CommandScope scope);
|
||||
|
||||
/** Registers a command that only ever affects the client. */
|
||||
boolean register(String name, ClientCommand handler);
|
||||
|
||||
/** True when some mod registered the name. */
|
||||
boolean isRegistered(String name);
|
||||
|
||||
/** The scope a name was registered with, or null when it is not registered. */
|
||||
CommandScope scopeOf(String name);
|
||||
|
||||
/** How many commands are registered. */
|
||||
int count();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* Keyboard and mouse state the client already tracks.
|
||||
*
|
||||
* Read only. A client mod can watch which key is held so its overlay can react,
|
||||
* and nothing more: it cannot inject input into the game, and this says nothing
|
||||
* about the world.
|
||||
*/
|
||||
public interface Input {
|
||||
|
||||
/**
|
||||
* True while a key bound to the given id is held.
|
||||
*
|
||||
* The id is the game's own, for example "key.sneak", "key.jump",
|
||||
* "key.forward". An unknown id is simply not held, so a mod written against
|
||||
* a later version does not break here.
|
||||
*/
|
||||
boolean keyDown(String keyId);
|
||||
|
||||
/** True while any bound key is held. */
|
||||
boolean anyKeyDown();
|
||||
|
||||
/** True while the window has keyboard focus. */
|
||||
boolean focused();
|
||||
|
||||
/** Cursor X in interface pixels. */
|
||||
int mouseX();
|
||||
|
||||
/** Cursor Y in interface pixels. */
|
||||
int mouseY();
|
||||
|
||||
/** Bitmask of the mouse buttons currently down, LWJGL numbering. */
|
||||
int mouseButtons();
|
||||
|
||||
/** True while the given LWJGL mouse button is down. */
|
||||
boolean mouseDown(int button);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* The client's own display state.
|
||||
*
|
||||
* Sizes are the scaled interface ones, the same coordinate space the health bar
|
||||
* and the chat live in, so a mod's overlay lines up with the interface at any
|
||||
* resolution or GUI scale without doing the arithmetic itself.
|
||||
*
|
||||
* There is no screen title here. The client does not keep one, and inventing a
|
||||
* label would hand a mod something that looks like the game's text and is not.
|
||||
*/
|
||||
public interface Screen {
|
||||
|
||||
/** What is showing. */
|
||||
ScreenKind kind();
|
||||
|
||||
/** Interface width in pixels. */
|
||||
int width();
|
||||
|
||||
/** Interface height in pixels. */
|
||||
int height();
|
||||
|
||||
/** Interface scale, where 2 means half size. */
|
||||
float scale();
|
||||
|
||||
/** Window width in pixels, before scaling. */
|
||||
int windowWidth();
|
||||
|
||||
/** Window height in pixels, before scaling. */
|
||||
int windowHeight();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package net.rustnotmc.api.client;
|
||||
|
||||
/**
|
||||
* What the client is showing right now.
|
||||
*
|
||||
* A mod needs this to decide whether an overlay makes sense: a map readout is
|
||||
* noise on the main menu, a "press play" hint is noise mid-combat. The values
|
||||
* describe the client's own presentation state and reveal nothing about the
|
||||
* world behind it.
|
||||
*/
|
||||
public enum ScreenKind {
|
||||
|
||||
/** The title screen. */
|
||||
MAIN_MENU,
|
||||
|
||||
/** Choosing which save to open. */
|
||||
WORLD_SELECTION,
|
||||
|
||||
/** Options, controls, video settings and friends. */
|
||||
OPTIONS,
|
||||
|
||||
/** The chat box has focus. */
|
||||
CHAT,
|
||||
|
||||
/** In a world, with a screen such as the pause menu open. */
|
||||
PAUSED,
|
||||
|
||||
/** A container screen, typically the inventory. */
|
||||
INVENTORY,
|
||||
|
||||
/** The advancements or statistics screen. */
|
||||
ADVANCEMENTS,
|
||||
|
||||
/** In a world with nothing open, which is when gameplay draws. */
|
||||
IN_GAME,
|
||||
|
||||
/** Something the client has no specific name for. */
|
||||
UNKNOWN;
|
||||
|
||||
/** True while a world is loaded, whether or not a screen covers it. */
|
||||
public boolean inWorld() {
|
||||
return this == IN_GAME || this == PAUSED || this == CHAT
|
||||
|| this == INVENTORY || this == ADVANCEMENTS;
|
||||
}
|
||||
|
||||
/** True when the client is drawing the world rather than a full screen. */
|
||||
public boolean gameplayVisible() {
|
||||
return this == IN_GAME;
|
||||
}
|
||||
|
||||
public static ScreenKind byName(String raw, ScreenKind fallback) {
|
||||
if (raw != null) {
|
||||
for (ScreenKind k : values()) {
|
||||
if (k.name().equalsIgnoreCase(raw.trim())) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package net.rustnotmc.api.hud;
|
||||
|
||||
/**
|
||||
* Where on the screen an overlay hangs.
|
||||
*
|
||||
* The point is not convenience but avoidance: the interface already owns the
|
||||
* corners, so a mod says which corner it wants instead of picking a pixel and
|
||||
* landing on top of the chat.
|
||||
*/
|
||||
public enum Anchor {
|
||||
|
||||
TOP_LEFT,
|
||||
TOP_CENTER,
|
||||
TOP_RIGHT,
|
||||
CENTER,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_CENTER,
|
||||
BOTTOM_RIGHT;
|
||||
|
||||
/** The left edge of a box of this width on a screen that wide. */
|
||||
public int x(int screenWidth, int width) {
|
||||
switch (this) {
|
||||
case TOP_CENTER:
|
||||
case CENTER:
|
||||
case BOTTOM_CENTER:
|
||||
return (screenWidth - width) / 2;
|
||||
case TOP_RIGHT:
|
||||
case BOTTOM_RIGHT:
|
||||
return screenWidth - width;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** The top edge of a box of this height on a screen that tall. */
|
||||
public int y(int screenHeight, int height) {
|
||||
switch (this) {
|
||||
case CENTER:
|
||||
return (screenHeight - height) / 2;
|
||||
case BOTTOM_LEFT:
|
||||
case BOTTOM_CENTER:
|
||||
case BOTTOM_RIGHT:
|
||||
return screenHeight - height;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isBottom() {
|
||||
return this == BOTTOM_LEFT || this == BOTTOM_CENTER || this == BOTTOM_RIGHT;
|
||||
}
|
||||
|
||||
public boolean isCentre() {
|
||||
return this == CENTER;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor with this name, or the fallback.
|
||||
*
|
||||
* Config files are user input, and a typo in one must not take the HUD down.
|
||||
*/
|
||||
public static Anchor byName(String name, Anchor fallback) {
|
||||
if (name != null) {
|
||||
String v = name.trim();
|
||||
for (Anchor a : values()) {
|
||||
if (a.name().equalsIgnoreCase(v)) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
if (v.equalsIgnoreCase("left-top")) {
|
||||
return TOP_LEFT;
|
||||
}
|
||||
if (v.equalsIgnoreCase("right-top")) {
|
||||
return TOP_RIGHT;
|
||||
}
|
||||
if (v.equalsIgnoreCase("center-top")) {
|
||||
return TOP_CENTER;
|
||||
}
|
||||
if (v.equalsIgnoreCase("center-bottom")) {
|
||||
return BOTTOM_CENTER;
|
||||
}
|
||||
if (v.equalsIgnoreCase("left-bottom")) {
|
||||
return BOTTOM_LEFT;
|
||||
}
|
||||
if (v.equalsIgnoreCase("right-bottom")) {
|
||||
return BOTTOM_RIGHT;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package net.rustnotmc.api.hud;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import net.rustnotmc.loader.Log;
|
||||
|
||||
/**
|
||||
* Where every overlay sits, and what draws over what.
|
||||
*
|
||||
* An anchor is a corner, an edge or the middle of the screen, so an element
|
||||
* stays in its corner at any resolution or interface scale instead of sitting at
|
||||
* a pixel a mod picked once. A layer says what covers what, on the scale graphics
|
||||
* APIs use, with the game itself at zero.
|
||||
*
|
||||
* The sorted order is cached and rebuilt only when something actually changes.
|
||||
* The old layout re-copied and re-sorted its slot list on every single frame,
|
||||
* which at 60 frames a second is pure waste for a list that changes a few times
|
||||
* a minute.
|
||||
*/
|
||||
public final class HudLayout {
|
||||
|
||||
/** The game draws itself around this layer. */
|
||||
public static final int GAME = 0;
|
||||
|
||||
/** Below the game, for anything that must not fight the interface. */
|
||||
public static final int BACKGROUND = -1000;
|
||||
|
||||
/** The default for an element that does not care. */
|
||||
public static final int NORMAL = 100;
|
||||
|
||||
/** What draws an overlay: measures itself, then draws in the box it is given. */
|
||||
public interface Draw {
|
||||
int width(HudSurface hud);
|
||||
|
||||
int height(HudSurface hud);
|
||||
|
||||
void draw(HudSurface hud, int x, int y, int width, int height);
|
||||
}
|
||||
|
||||
/** One registered overlay. */
|
||||
public static final class Slot {
|
||||
|
||||
private final String owner;
|
||||
private final Draw draw;
|
||||
private final int order;
|
||||
private Anchor anchor;
|
||||
private int layer;
|
||||
private int offsetX;
|
||||
private int offsetY;
|
||||
|
||||
Slot(String owner, Draw draw, Anchor anchor, int layer, int offsetX, int offsetY,
|
||||
int order) {
|
||||
this.owner = owner;
|
||||
this.draw = draw;
|
||||
this.anchor = anchor;
|
||||
this.layer = layer;
|
||||
this.offsetX = offsetX;
|
||||
this.offsetY = offsetY;
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
public void anchor(Anchor value) {
|
||||
this.anchor = value == null ? Anchor.TOP_LEFT : value;
|
||||
HudLayout.markDirty();
|
||||
}
|
||||
|
||||
public void layer(int value) {
|
||||
this.layer = value;
|
||||
HudLayout.markDirty();
|
||||
}
|
||||
|
||||
public void offset(int x, int y) {
|
||||
this.offsetX = x;
|
||||
this.offsetY = y;
|
||||
}
|
||||
|
||||
public Anchor anchor() {
|
||||
return anchor;
|
||||
}
|
||||
|
||||
public int layer() {
|
||||
return layer;
|
||||
}
|
||||
|
||||
public int offsetX() {
|
||||
return offsetX;
|
||||
}
|
||||
|
||||
public int offsetY() {
|
||||
return offsetY;
|
||||
}
|
||||
|
||||
public String owner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Layer and then insertion order, so a panel stays above its own label. */
|
||||
int compareTo(Slot other) {
|
||||
if (layer != other.layer) {
|
||||
return layer < other.layer ? -1 : 1;
|
||||
}
|
||||
return order < other.order ? -1 : (order > other.order ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Object LOCK = new Object();
|
||||
private static final List<Slot> SLOTS = new ArrayList<Slot>();
|
||||
private static List<Slot> sorted = Collections.emptyList();
|
||||
private static boolean dirty = true;
|
||||
private static boolean warnedAboutSize;
|
||||
private static int orderCounter;
|
||||
|
||||
private HudLayout() {
|
||||
}
|
||||
|
||||
/** Registers an overlay and returns the slot that controls it. */
|
||||
public static Slot add(String owner, Draw draw, Anchor anchor, int layer,
|
||||
int offsetX, int offsetY) {
|
||||
if (draw == null) {
|
||||
return null;
|
||||
}
|
||||
Slot slot;
|
||||
synchronized (LOCK) {
|
||||
slot = new Slot(owner, draw, anchor == null ? Anchor.TOP_LEFT : anchor,
|
||||
layer, offsetX, offsetY, orderCounter++);
|
||||
SLOTS.add(slot);
|
||||
dirty = true;
|
||||
}
|
||||
Log.info("HUD: \"" + owner + "\" at " + slot.anchor.id() + " layer " + layer);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Registers an overlay at the normal layer, flush with its corner. */
|
||||
public static Slot add(String owner, Draw draw, Anchor anchor) {
|
||||
return add(owner, draw, anchor, NORMAL, 0, 0);
|
||||
}
|
||||
|
||||
/** Drops every overlay of one mod. */
|
||||
public static void remove(String owner) {
|
||||
synchronized (LOCK) {
|
||||
for (int i = 0; i < SLOTS.size(); i++) {
|
||||
if (SLOTS.get(i).owner.equals(owner)) {
|
||||
SLOTS.remove(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int size() {
|
||||
synchronized (LOCK) {
|
||||
return SLOTS.size();
|
||||
}
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
synchronized (LOCK) {
|
||||
SLOTS.clear();
|
||||
sorted = Collections.emptyList();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void markDirty() {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
private static List<Slot> ordered() {
|
||||
synchronized (LOCK) {
|
||||
if (!dirty && sorted.size() == SLOTS.size()) {
|
||||
return sorted;
|
||||
}
|
||||
List<Slot> copy = new ArrayList<Slot>(SLOTS);
|
||||
Collections.sort(copy, new Comparator<Slot>() {
|
||||
public int compare(Slot a, Slot b) {
|
||||
return a.compareTo(b);
|
||||
}
|
||||
});
|
||||
sorted = copy;
|
||||
dirty = false;
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Places and draws everything, lowest layer first.
|
||||
*
|
||||
* A failing overlay is reported and skipped: one bad element must not blank
|
||||
* the whole interface.
|
||||
*/
|
||||
public static void render(HudSurface hud) {
|
||||
if (hud == null || !hud.available()) {
|
||||
return;
|
||||
}
|
||||
List<Slot> slots = ordered();
|
||||
if (slots.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int screenW = hud.width();
|
||||
int screenH = hud.height();
|
||||
if (screenW <= 0 || screenH <= 0) {
|
||||
if (!warnedAboutSize) {
|
||||
warnedAboutSize = true;
|
||||
Log.warn("HUD: screen size unknown, overlays hang off the top"
|
||||
+ " edge until it can be measured");
|
||||
}
|
||||
screenW = Math.max(1, screenW);
|
||||
screenH = 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < slots.size(); i++) {
|
||||
Slot slot = slots.get(i);
|
||||
try {
|
||||
int w = Math.max(0, slot.draw.width(hud));
|
||||
int h = Math.max(0, slot.draw.height(hud));
|
||||
Anchor anchor = slot.anchor;
|
||||
if (screenH == 0 && anchor != Anchor.TOP_LEFT && anchor != Anchor.TOP_RIGHT) {
|
||||
anchor = anchor.isBottom() ? anchorForTop(anchor) : Anchor.TOP_CENTER;
|
||||
}
|
||||
int x = Math.max(0, anchor.x(screenW, w) + slot.offsetX);
|
||||
int y = Math.max(0, anchor.y(screenH, h) + slot.offsetY);
|
||||
slot.draw.draw(hud, x, y, w, h);
|
||||
} catch (Throwable t) {
|
||||
Log.error("HUD element \"" + slot.owner + "\" failed", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Anchor anchorForTop(Anchor bottom) {
|
||||
switch (bottom) {
|
||||
case BOTTOM_LEFT:
|
||||
return Anchor.TOP_LEFT;
|
||||
case BOTTOM_RIGHT:
|
||||
return Anchor.TOP_RIGHT;
|
||||
default:
|
||||
return Anchor.TOP_CENTER;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package net.rustnotmc.api.hud;
|
||||
|
||||
/**
|
||||
* What a mod draws through.
|
||||
*
|
||||
* The client supplies the implementation, backed by the game's own font and
|
||||
* interface scale, so a panel sits in the same layer as the health bars and the
|
||||
* compass and follows the game's show/hide state. Mods never see the game types
|
||||
* behind it, which is what lets a mod be tested without the game running.
|
||||
*/
|
||||
public interface HudSurface {
|
||||
|
||||
/** False while the game is not drawing its interface. */
|
||||
boolean available();
|
||||
|
||||
/** Screen width in pixels, 0 when unknown. */
|
||||
int width();
|
||||
|
||||
/** Screen height in pixels, 0 when unknown. */
|
||||
int height();
|
||||
|
||||
/** The interface scale the game draws with. */
|
||||
float scale();
|
||||
|
||||
/** A filled rectangle; alpha is 0..1. */
|
||||
void rect(float x, float y, float w, float h, int rgb, float alpha);
|
||||
|
||||
/** Text in the game's own font. */
|
||||
void text(float x, float y, String value, int rgb, float alpha);
|
||||
|
||||
/** Width of a string, so an element can size itself. */
|
||||
float textWidth(String value);
|
||||
|
||||
/** Height of one line. */
|
||||
int textHeight();
|
||||
|
||||
/** Surface that draws nothing, handed out when the game is not ready. */
|
||||
HudSurface NULL = new HudSurface() {
|
||||
public boolean available() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public int width() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public float scale() {
|
||||
return 1f;
|
||||
}
|
||||
|
||||
public void rect(float x, float y, float w, float h, int rgb, float alpha) {
|
||||
}
|
||||
|
||||
public void text(float x, float y, String value, int rgb, float alpha) {
|
||||
}
|
||||
|
||||
public float textWidth(String value) {
|
||||
return 0f;
|
||||
}
|
||||
|
||||
public int textHeight() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a client mod is allowed to touch.
|
||||
*
|
||||
* The rule this set encodes is narrow on purpose. The server owns the world:
|
||||
* blocks, items, players, physics, combat numbers. A client mod may change what
|
||||
* the client shows, never what is true. Everything that could turn a visual
|
||||
* tweak into a cheat or a corrupted world was removed from this list rather than
|
||||
* left in place with nothing behind it.
|
||||
*
|
||||
* Every value here is enforced somewhere in the loader. A capability that is
|
||||
* granted but unused is a lie told to mod authors, which is why the old set of
|
||||
* WORLD, PLAYER, NETWORK, TEXT, INPUT and COMMAND is gone: nothing checked them.
|
||||
*
|
||||
* A mod requests what it needs; the loader grants CORE plus anything the
|
||||
* descriptor declares. REFLECTION is the escape hatch and is never automatic.
|
||||
*/
|
||||
public enum Access {
|
||||
|
||||
/** Lifecycle, logging, scheduler, event bus. Always granted. */
|
||||
CORE,
|
||||
|
||||
/** Register overlay elements on the screen, drawn with the game's font. */
|
||||
HUD,
|
||||
|
||||
/**
|
||||
* Read the keyboard and mouse state the client already knows.
|
||||
*
|
||||
* Presentation only: which key is held, where the cursor is, whether the
|
||||
* window has focus. This says nothing about the world and changes nothing in
|
||||
* it.
|
||||
*/
|
||||
INPUT,
|
||||
|
||||
/**
|
||||
* Register commands typed by the player.
|
||||
*
|
||||
* A CLIENT command runs in the client and may only affect the client. A
|
||||
* SERVER or BOTH command is the server's to decide; the client half never
|
||||
* applies an effect on its own.
|
||||
*/
|
||||
COMMANDS,
|
||||
|
||||
/**
|
||||
* Read and write files inside the mod's own directory, nothing else.
|
||||
*
|
||||
* The game directory and every other mod's data are out of reach, because a
|
||||
* save file is world data.
|
||||
*/
|
||||
FILES,
|
||||
|
||||
/**
|
||||
* Raw reflective access to any game class.
|
||||
*
|
||||
* Not a security sandbox, and deliberately unstable: it can read a field
|
||||
* that does not exist in this build and take the client down. It exists for
|
||||
* the mod that needs something this API does not cover yet, and it is
|
||||
* granted by hand, never by default.
|
||||
*/
|
||||
REFLECTION;
|
||||
|
||||
/** Granted to every mod without being asked for. */
|
||||
public static final Set<Access> DEFAULT_GRANTS =
|
||||
Collections.unmodifiableSet(EnumSet.of(CORE, HUD));
|
||||
|
||||
/**
|
||||
* Names that used to be accepted and now are not.
|
||||
*
|
||||
* These were never backed by an implementation, so granting them promised a
|
||||
* mod access it could not use. They are kept only to explain the removal:
|
||||
* "world" is a policy decision, not a typo, and a mod author deserves to
|
||||
* read that rather than a generic "unknown key".
|
||||
*/
|
||||
private static final List<String> RETIRED =
|
||||
Collections.unmodifiableList(Arrays.asList(
|
||||
"world", "player", "network", "text", "items", "entity"));
|
||||
|
||||
public boolean grantedByDefault() {
|
||||
return DEFAULT_GRANTS.contains(this);
|
||||
}
|
||||
|
||||
public boolean dangerous() {
|
||||
return this == REFLECTION;
|
||||
}
|
||||
|
||||
/** The value a descriptor writes, e.g. "hud" for HUD. */
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
|
||||
/** True when the name was a capability that has been removed on purpose. */
|
||||
public static boolean isRetired(String raw) {
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
return RETIRED.contains(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
public static Access byId(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String v = raw.trim();
|
||||
if (v.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
for (Access a : values()) {
|
||||
if (a.name().equalsIgnoreCase(v)) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
// tolerate the plural spelling, "huds" and "inputs"
|
||||
if (v.endsWith("s")) {
|
||||
return byId(v.substring(0, v.length() - 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses a comma separated capability list, skipping what it cannot read. */
|
||||
public static Set<Access> parseList(String raw) {
|
||||
Set<Access> out = EnumSet.noneOf(Access.class);
|
||||
if (raw == null) {
|
||||
return out;
|
||||
}
|
||||
List<String> bad = new ArrayList<String>();
|
||||
List<String> retired = new ArrayList<String>();
|
||||
for (String piece : raw.split(",")) {
|
||||
String v = piece.trim();
|
||||
if (v.length() == 0) {
|
||||
continue;
|
||||
}
|
||||
Access a = byId(v);
|
||||
if (a != null) {
|
||||
out.add(a);
|
||||
} else if (isRetired(v)) {
|
||||
retired.add(v);
|
||||
} else {
|
||||
bad.add(v);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < retired.size(); i++) {
|
||||
Log.warn("capability '" + retired.get(i) + "' was removed: a client mod"
|
||||
+ " cannot reach the world, players, items or the network");
|
||||
}
|
||||
if (!bad.isEmpty()) {
|
||||
Log.warn("unknown capability in descriptor: " + join(bad));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String join(List<String> items) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(items.get(i));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Typed event dispatch.
|
||||
*
|
||||
* The previous bridge matched handlers by string and walked the whole list every
|
||||
* tick, so cost grew with the number of subscriptions regardless of type. Here
|
||||
* handlers are indexed by event class, so posting a TickEvent only touches the
|
||||
* tick handlers.
|
||||
*
|
||||
* A handler that throws is reported and skipped; it never stops the ones behind
|
||||
* it. One broken mod must not silence the rest.
|
||||
*/
|
||||
public final class EventBus {
|
||||
|
||||
/** Runs before everything else. */
|
||||
public static final int HIGHEST = 32767;
|
||||
/** Runs last. */
|
||||
public static final int LOWEST = -32768;
|
||||
/** The default. */
|
||||
public static final int NORMAL = 0;
|
||||
|
||||
/** Handle to one registration, so a mod can drop it again. */
|
||||
public static final class Subscription {
|
||||
private final EventBus bus;
|
||||
private final Class<?> type;
|
||||
private final Consumer<Object> handler;
|
||||
private volatile boolean cancelled;
|
||||
|
||||
Subscription(EventBus bus, Class<?> type, Consumer<Object> handler) {
|
||||
this.bus = bus;
|
||||
this.type = type;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/** Stops the handler being called again. Safe from inside a handler. */
|
||||
public void cancel() {
|
||||
cancelled = true;
|
||||
bus.remove(this);
|
||||
}
|
||||
|
||||
public boolean isCancelled() {
|
||||
return cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Handler {
|
||||
final String mod;
|
||||
final Class<?> type;
|
||||
final Consumer<Object> body;
|
||||
final int priority;
|
||||
final int order;
|
||||
final Subscription subscription;
|
||||
|
||||
Handler(String mod, Class<?> type, Consumer<Object> body, int priority,
|
||||
int order, Subscription subscription) {
|
||||
this.mod = mod;
|
||||
this.type = type;
|
||||
this.body = body;
|
||||
this.priority = priority;
|
||||
this.order = order;
|
||||
this.subscription = subscription;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<Class<?>, List<Handler>> byType = new ConcurrentHashMap<Class<?>, List<Handler>>();
|
||||
private final Map<Class<?>, List<Handler>> resolved = new ConcurrentHashMap<Class<?>, List<Handler>>();
|
||||
private final Object lock = new Object();
|
||||
private int orderCounter;
|
||||
private volatile boolean dirty = true;
|
||||
|
||||
public <T> Subscription subscribe(String modId, Class<T> type, int priority,
|
||||
Consumer<T> body) {
|
||||
if (type == null || body == null) {
|
||||
throw new IllegalArgumentException("type and body are required");
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<Object> erased = (Consumer<Object>) body;
|
||||
Subscription sub = new Subscription(this, type, erased);
|
||||
Handler h = new Handler(modId == null ? "?" : modId, type, erased, priority,
|
||||
orderCounter++, sub);
|
||||
synchronized (lock) {
|
||||
List<Handler> list = byType.get(type);
|
||||
if (list == null) {
|
||||
list = new ArrayList<Handler>();
|
||||
byType.put(type, list);
|
||||
}
|
||||
list.add(h);
|
||||
dirty = true;
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
public <T> Subscription subscribe(String modId, Class<T> type, Consumer<T> body) {
|
||||
return subscribe(modId, type, NORMAL, body);
|
||||
}
|
||||
|
||||
private void remove(Subscription sub) {
|
||||
synchronized (lock) {
|
||||
List<Handler> list = byType.get(sub.type);
|
||||
if (list == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
if (list.get(i).subscription == sub) {
|
||||
list.remove(i);
|
||||
dirty = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of handlers that would run for this type, across its supertypes. */
|
||||
public int handlerCount(Class<?> type) {
|
||||
return handlersFor(type).size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the event to every handler registered for its type or a supertype,
|
||||
* highest priority first, then in subscription order.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T post(T event) {
|
||||
if (event == null) {
|
||||
return null;
|
||||
}
|
||||
for (Handler h : handlersFor(event.getClass())) {
|
||||
if (h.subscription.isCancelled()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
h.body.accept(event);
|
||||
} catch (Throwable t) {
|
||||
Log.error("handler of " + h.mod + " failed on " + event.getClass().getSimpleName(), t);
|
||||
}
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Post that stops at the first handler asking for it, for cancelable events. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T postUntilCancelled(T event, java.util.function.Predicate<T> cancelTest) {
|
||||
if (event == null) {
|
||||
return null;
|
||||
}
|
||||
for (Handler h : handlersFor(event.getClass())) {
|
||||
if (h.subscription.isCancelled()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
h.body.accept(event);
|
||||
} catch (Throwable t) {
|
||||
Log.error("handler of " + h.mod + " failed on " + event.getClass().getSimpleName(), t);
|
||||
}
|
||||
if (cancelTest != null && cancelTest.test(event)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Handler> handlersFor(Class<?> type) {
|
||||
List<Handler> cache = resolved.get(type);
|
||||
if (cache != null && !dirty) {
|
||||
return cache;
|
||||
}
|
||||
synchronized (lock) {
|
||||
cache = new ArrayList<Handler>();
|
||||
for (Map.Entry<Class<?>, List<Handler>> e : byType.entrySet()) {
|
||||
Class<?> registered = e.getKey();
|
||||
if (registered.isAssignableFrom(type)) {
|
||||
cache.addAll(e.getValue());
|
||||
}
|
||||
}
|
||||
java.util.Collections.sort(cache, new java.util.Comparator<Handler>() {
|
||||
public int compare(Handler a, Handler b) {
|
||||
if (a.priority != b.priority) {
|
||||
return a.priority > b.priority ? -1 : 1;
|
||||
}
|
||||
return a.order < b.order ? -1 : (a.order > b.order ? 1 : 0);
|
||||
}
|
||||
});
|
||||
cache = java.util.Collections.unmodifiableList(cache);
|
||||
resolved.clear();
|
||||
resolved.put(type, cache);
|
||||
dirty = false;
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every registration. Used when the game shuts down. */
|
||||
public void clear() {
|
||||
synchronized (lock) {
|
||||
byType.clear();
|
||||
resolved.clear();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Diagnostics for the log. */
|
||||
public Map<String, Integer> summary() {
|
||||
Map<String, Integer> out = new HashMap<String, Integer>();
|
||||
synchronized (lock) {
|
||||
for (Map.Entry<Class<?>, List<Handler>> e : byType.entrySet()) {
|
||||
out.put(e.getKey().getSimpleName(), e.getValue().size());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* A mod's handle on the game, subject to the capabilities it was granted.
|
||||
*
|
||||
* Two things happen here that the old bridge did badly.
|
||||
*
|
||||
* <b>Caching.</b> The old resolver took a class name and a member name, then
|
||||
* walked the class with reflection on every single call. A HUD redrawing text
|
||||
* does hundreds of those per frame. Handles are resolved once and kept, so the
|
||||
* steady state is a map lookup and an array read.
|
||||
*
|
||||
* <b>Permission.</b> Every access is checked against the mod's capability set.
|
||||
* A mod that never asked for WORLD cannot read the world, even though it sits in
|
||||
* the same process and could work it out for itself.
|
||||
*
|
||||
* The client runs on a MCP-named jar, so a logical name usually already is the
|
||||
* real name. A mapping file exists for the cases where it is not, and is only
|
||||
* consulted when one is loaded.
|
||||
*/
|
||||
public final class GameAccess {
|
||||
|
||||
private static final Map<String, Class<?>> CLASSES = new ConcurrentHashMap<String, Class<?>>();
|
||||
private static final Map<String, Field> FIELDS = new ConcurrentHashMap<String, Field>();
|
||||
private static final Map<String, Method> METHODS = new ConcurrentHashMap<String, Method>();
|
||||
private static final Map<String, Boolean> REPORTED = new ConcurrentHashMap<String, Boolean>();
|
||||
|
||||
private static Properties mapping = new Properties();
|
||||
|
||||
private final String modId;
|
||||
private final Set<Access> granted;
|
||||
|
||||
public GameAccess(String modId, Set<Access> granted) {
|
||||
this.modId = modId;
|
||||
if (granted == null || granted.isEmpty()) {
|
||||
this.granted = Collections.emptySet();
|
||||
} else {
|
||||
Set<Access> copy = java.util.EnumSet.noneOf(Access.class);
|
||||
copy.addAll(granted);
|
||||
this.granted = Collections.unmodifiableSet(copy);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the optional logical-to-real name table. */
|
||||
public static void loadMapping(File file) {
|
||||
if (file == null || !file.isFile()) {
|
||||
return;
|
||||
}
|
||||
InputStream in = null;
|
||||
try {
|
||||
in = new FileInputStream(file);
|
||||
Properties p = new Properties();
|
||||
p.load(in);
|
||||
mapping = p;
|
||||
CLASSES.clear();
|
||||
FIELDS.clear();
|
||||
METHODS.clear();
|
||||
Log.info("name mapping loaded from " + file.getName() + " ("
|
||||
+ p.size() + " entries)");
|
||||
} catch (Throwable t) {
|
||||
Log.warn("cannot read name mapping " + file + ": " + t);
|
||||
} finally {
|
||||
if (in != null) {
|
||||
try {
|
||||
in.close();
|
||||
} catch (Throwable ignored) {
|
||||
// read-only stream, nothing to recover
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearMapping() {
|
||||
mapping = new Properties();
|
||||
CLASSES.clear();
|
||||
FIELDS.clear();
|
||||
METHODS.clear();
|
||||
}
|
||||
|
||||
/** True when the mod holds the capability. */
|
||||
public boolean can(Access capability) {
|
||||
return granted.contains(capability);
|
||||
}
|
||||
|
||||
/** True when every listed capability is held. */
|
||||
public boolean canAll(Access... capabilities) {
|
||||
for (int i = 0; i < capabilities.length; i++) {
|
||||
if (!granted.contains(capabilities[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public Set<Access> capabilities() {
|
||||
return granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a capability at runtime.
|
||||
*
|
||||
* REFLECTION is the one worth calling out: it is refused unless the mod was
|
||||
* granted it, because it is the only way to reach game internals that the
|
||||
* typed API does not cover.
|
||||
*/
|
||||
public boolean request(Access capability) {
|
||||
if (can(capability)) {
|
||||
return true;
|
||||
}
|
||||
if (capability == Access.REFLECTION) {
|
||||
Log.warn("mod " + modId + " asked for raw reflection without being granted it");
|
||||
} else {
|
||||
Log.warn("mod " + modId + " asked for " + capability.id()
|
||||
+ " without declaring it");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ resolution
|
||||
|
||||
public Class<?> cls(String logical) {
|
||||
if (logical == null) {
|
||||
return null;
|
||||
}
|
||||
if (!can(Access.REFLECTION)) {
|
||||
// resolving a game class by name is the escape hatch, so it needs the
|
||||
// escape hatch; CORE is granted to everything and would gate nothing
|
||||
return null;
|
||||
}
|
||||
Class<?> c = CLASSES.get(logical);
|
||||
if (c != null) {
|
||||
return c;
|
||||
}
|
||||
String real = property("class." + logical, logical);
|
||||
try {
|
||||
c = Class.forName(real, false, loader());
|
||||
CLASSES.put(logical, c);
|
||||
return c;
|
||||
} catch (Throwable t) {
|
||||
reportOnce("class:" + logical, "mod " + modId + ": no class " + real
|
||||
+ " (asked as '" + logical + "')");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Field field(String logicalClass, String name) {
|
||||
Class<?> owner = cls(logicalClass);
|
||||
if (owner == null || name == null) {
|
||||
return null;
|
||||
}
|
||||
String key = logicalClass + '#' + name;
|
||||
Field f = FIELDS.get(key);
|
||||
if (f != null) {
|
||||
return f;
|
||||
}
|
||||
String real = property("field." + logicalClass + '#' + name, name);
|
||||
try {
|
||||
f = owner.getDeclaredField(real);
|
||||
f.trySetAccessible();
|
||||
FIELDS.put(key, f);
|
||||
return f;
|
||||
} catch (Throwable t) {
|
||||
reportOnce("field:" + key, "mod " + modId + ": no field " + logicalClass
|
||||
+ '#' + real);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Method method(String logicalClass, String name, Class<?>... params) {
|
||||
Class<?> owner = cls(logicalClass);
|
||||
if (owner == null || name == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(logicalClass).append('#').append(name);
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
sb.append('/').append(params[i].getName());
|
||||
}
|
||||
}
|
||||
String key = sb.toString();
|
||||
Method m = METHODS.get(key);
|
||||
if (m != null) {
|
||||
return m;
|
||||
}
|
||||
String real = property("method." + logicalClass + '#' + name, name);
|
||||
try {
|
||||
m = owner.getDeclaredMethod(real, params);
|
||||
m.trySetAccessible();
|
||||
METHODS.put(key, m);
|
||||
return m;
|
||||
} catch (Throwable t) {
|
||||
reportOnce("method:" + key, "mod " + modId + ": no method " + logicalClass
|
||||
+ '#' + real);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accessors
|
||||
|
||||
public Object get(String logicalClass, String member, Object instance) {
|
||||
Field f = field(logicalClass, member);
|
||||
if (f == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return f.get(instance);
|
||||
} catch (Throwable t) {
|
||||
reportOnce("read:" + logicalClass + '#' + member,
|
||||
"mod " + modId + " cannot read " + logicalClass + '#' + member);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void set(String logicalClass, String member, Object instance, Object value) {
|
||||
Field f = field(logicalClass, member);
|
||||
if (f == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
f.set(instance, value);
|
||||
} catch (Throwable t) {
|
||||
reportOnce("write:" + logicalClass + '#' + member,
|
||||
"mod " + modId + " cannot write " + logicalClass + '#' + member);
|
||||
}
|
||||
}
|
||||
|
||||
public Object call(String logicalClass, String member, Object instance, Object... args) {
|
||||
Method m = method(logicalClass, member, argTypes(args));
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return m.invoke(instance, args);
|
||||
} catch (Throwable t) {
|
||||
Throwable cause = t.getCause() == null ? t : t.getCause();
|
||||
reportOnce("call:" + logicalClass + '#' + member,
|
||||
"mod " + modId + " failed calling " + logicalClass + '#' + member
|
||||
+ ": " + cause);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Class<?>[] argTypes(Object[] args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
Class<?>[] out = new Class<?>[args.length];
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
out[i] = args[i] == null
|
||||
? Object.class
|
||||
: args[i].getClass();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final Class<?>[] EMPTY = new Class<?>[0];
|
||||
|
||||
private static String property(String key, String fallback) {
|
||||
String v = mapping.getProperty(key);
|
||||
if (v == null || v.trim().length() == 0) {
|
||||
return fallback;
|
||||
}
|
||||
return v.trim();
|
||||
}
|
||||
|
||||
private static ClassLoader loader() {
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
return cl != null ? cl : GameAccess.class.getClassLoader();
|
||||
}
|
||||
|
||||
private static void reportOnce(String key, String message) {
|
||||
if (REPORTED.putIfAbsent(key, Boolean.TRUE) == null) {
|
||||
Log.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything still unresolved, for a diagnostic dump. */
|
||||
public static List<String> unresolved() {
|
||||
List<String> out = new ArrayList<String>(REPORTED.keySet());
|
||||
Collections.sort(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drops every cached handle. Called when the client changes world or version. */
|
||||
public static void invalidate() {
|
||||
CLASSES.clear();
|
||||
FIELDS.clear();
|
||||
METHODS.clear();
|
||||
REPORTED.clear();
|
||||
}
|
||||
|
||||
public String modId() {
|
||||
return modId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.Charset;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Loader log.
|
||||
*
|
||||
* The game runs windowed under javaw.exe where nobody sees stdout, so everything
|
||||
* goes to a file next to the loader; that file is the primary way to confirm a
|
||||
* mod actually ran.
|
||||
*
|
||||
* {@link #forMod} hands a mod a view that prefixes its id, so a line from a mod
|
||||
* is attributable without every call site repeating its own name.
|
||||
*/
|
||||
public final class Log {
|
||||
|
||||
public static final int DEBUG = 0;
|
||||
public static final int INFO = 1;
|
||||
public static final int WARN = 2;
|
||||
public static final int ERROR = 3;
|
||||
|
||||
private static final Charset UTF8 = Charset.forName("UTF-8");
|
||||
private static final SimpleDateFormat FMT = new SimpleDateFormat("HH:mm:ss.SSS");
|
||||
|
||||
private static PrintWriter file;
|
||||
private static int threshold = INFO;
|
||||
|
||||
private Log() {
|
||||
}
|
||||
|
||||
/** Opens the log file. Passing null keeps console output only. */
|
||||
public static synchronized void open(File target) {
|
||||
close();
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File dir = target.getParentFile();
|
||||
if (dir != null) {
|
||||
dir.mkdirs();
|
||||
}
|
||||
Writer w = new OutputStreamWriter(new FileOutputStream(target, true), UTF8);
|
||||
file = new PrintWriter(w, true);
|
||||
info("--- RustNotMC loader log opened: " + target.getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
file = null;
|
||||
System.err.println("[rustnotmc] cannot open log: " + t);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void close() {
|
||||
if (file != null) {
|
||||
file.flush();
|
||||
file.close();
|
||||
file = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void setLevel(int level) {
|
||||
threshold = level;
|
||||
}
|
||||
|
||||
public static synchronized boolean debugEnabled() {
|
||||
return threshold <= DEBUG;
|
||||
}
|
||||
|
||||
public static synchronized void debug(String msg) {
|
||||
if (threshold <= DEBUG) {
|
||||
write("DEBUG", msg, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void info(String msg) {
|
||||
write("INFO", msg, null);
|
||||
}
|
||||
|
||||
public static synchronized void warn(String msg) {
|
||||
write("WARN", msg, null);
|
||||
}
|
||||
|
||||
public static synchronized void warn(String msg, Throwable t) {
|
||||
write("WARN", msg, t);
|
||||
}
|
||||
|
||||
public static synchronized void error(String msg) {
|
||||
write("ERROR", msg, null);
|
||||
}
|
||||
|
||||
public static synchronized void error(String msg, Throwable t) {
|
||||
write("ERROR", msg, t);
|
||||
}
|
||||
|
||||
/** A log view that stamps every line with a mod id. */
|
||||
public static ModLog forMod(String modId) {
|
||||
return new ModLog(modId);
|
||||
}
|
||||
|
||||
private static synchronized void write(String level, String msg, Throwable t) {
|
||||
String line = FMT.format(new Date()) + " [" + level + "] " + msg;
|
||||
System.out.println("[rustnotmc] " + line);
|
||||
if (t != null) {
|
||||
System.out.println(" " + rootMessage(t));
|
||||
}
|
||||
if (file != null) {
|
||||
file.println(line);
|
||||
if (t != null) {
|
||||
file.println(" " + rootMessage(t));
|
||||
}
|
||||
if (level.equals("ERROR")) {
|
||||
file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable root = t;
|
||||
while (root != null && root.getCause() != null && root.getCause() != root) {
|
||||
root = root.getCause();
|
||||
}
|
||||
return root.getClass().getName() + ": " + root.getMessage();
|
||||
}
|
||||
|
||||
/** Per-mod log view handed to a mod through its context. */
|
||||
public static final class ModLog {
|
||||
|
||||
private final String id;
|
||||
|
||||
ModLog(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public void debug(String msg) {
|
||||
if (debugEnabled()) {
|
||||
info(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public void info(String msg) {
|
||||
Log.info("[" + id + "] " + msg);
|
||||
}
|
||||
|
||||
public void warn(String msg) {
|
||||
Log.warn("[" + id + "] " + msg);
|
||||
}
|
||||
|
||||
public void warn(String msg, Throwable t) {
|
||||
Log.warn("[" + id + "] " + msg, t);
|
||||
}
|
||||
|
||||
public void error(String msg) {
|
||||
Log.error("[" + id + "] " + msg);
|
||||
}
|
||||
|
||||
public void error(String msg, Throwable t) {
|
||||
Log.error("[" + id + "] " + msg, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One loaded mod: its jar, its descriptor, its class loader and its instance.
|
||||
*
|
||||
* Each mod gets its own {@link java.net.URLClassLoader}. Two mods may ship a
|
||||
* class with the same name without colliding, and a mod cannot reach into another
|
||||
* mod's classes. The loader and API classes are deliberately not on the child
|
||||
* class loader, so every mod sees one copy of them and a mod never reloads the
|
||||
* scheduler or the event bus into a second instance.
|
||||
*/
|
||||
public final class ModContainer {
|
||||
|
||||
private final ModDescriptor descriptor;
|
||||
private final ClassLoader loader;
|
||||
private final Object instance;
|
||||
private final ModContext context;
|
||||
private final File jar;
|
||||
private final List<ModContainer> dependents = new ArrayList<ModContainer>();
|
||||
private volatile ModState state = ModState.DISCOVERED;
|
||||
|
||||
ModContainer(ModDescriptor descriptor, File jar, ClassLoader loader, Object instance,
|
||||
ModContext context) {
|
||||
this.descriptor = descriptor;
|
||||
this.jar = jar;
|
||||
this.loader = loader;
|
||||
this.instance = instance;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public ModDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return descriptor.id();
|
||||
}
|
||||
|
||||
public Version version() {
|
||||
return descriptor.version();
|
||||
}
|
||||
|
||||
public ModState state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
void state(ModState s) {
|
||||
this.state = s;
|
||||
}
|
||||
|
||||
public Object instance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ModContext context() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public ClassLoader classLoader() {
|
||||
return loader;
|
||||
}
|
||||
|
||||
public File jar() {
|
||||
return jar;
|
||||
}
|
||||
|
||||
/** Mods that list this one as a dependency, for shutdown ordering. */
|
||||
public List<ModContainer> dependents() {
|
||||
return Collections.unmodifiableList(dependents);
|
||||
}
|
||||
|
||||
void addDependent(ModContainer other) {
|
||||
if (!dependents.contains(other)) {
|
||||
dependents.add(other);
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls a lifecycle method on the mod, turning any throw into a log line. */
|
||||
boolean invoke(String method, String what) {
|
||||
try {
|
||||
java.lang.reflect.Method m = instance.getClass().getMethod(method);
|
||||
m.invoke(instance);
|
||||
return true;
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
Throwable cause = e.getCause() == null ? e : e.getCause();
|
||||
Log.error(descriptor.id() + " failed in " + what + " (" + method + ")", cause);
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
Log.error("cannot call " + method + " on " + descriptor.id()
|
||||
+ ": it may not extend the expected base class", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return descriptor.id() + " " + descriptor.version() + " [" + state + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a mod is handed when it is constructed.
|
||||
*
|
||||
* Everything a mod can do comes from here, so there is one place to look and one
|
||||
* place to cut off access. A mod is constructed once and keeps this context for
|
||||
* life; the fields never change.
|
||||
*/
|
||||
public final class ModContext {
|
||||
|
||||
private final String modId;
|
||||
private final String name;
|
||||
private final Version version;
|
||||
private final ModEnv env;
|
||||
private final Set<Access> access;
|
||||
private final GameAccess game;
|
||||
private final EventBus events;
|
||||
private final TickScheduler scheduler;
|
||||
private final Log.ModLog log;
|
||||
private final File modDir;
|
||||
|
||||
public ModContext(ModDescriptor d, Set<Access> granted, EventBus events,
|
||||
TickScheduler scheduler, File modDir) {
|
||||
this.modId = d.id();
|
||||
this.name = d.name();
|
||||
this.version = d.version();
|
||||
this.env = d.env();
|
||||
this.access = granted;
|
||||
this.game = new GameAccess(d.id(), granted);
|
||||
this.events = events;
|
||||
this.scheduler = scheduler;
|
||||
this.log = Log.forMod(d.id());
|
||||
this.modDir = modDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* This mod's own directory, created on first use.
|
||||
*
|
||||
* A mod gets its folder and nothing else. The game directory holds save
|
||||
* data, which the server owns, so handing out a general writable path would
|
||||
* hand out the world with it.
|
||||
*/
|
||||
public File modDirectory() {
|
||||
if (modDir == null) {
|
||||
return null;
|
||||
}
|
||||
if (!modDir.isDirectory() && !modDir.mkdirs()) {
|
||||
return null;
|
||||
}
|
||||
return modDir;
|
||||
}
|
||||
|
||||
/** Stable id, also used as the dependency key and the log prefix. */
|
||||
public String modId() {
|
||||
return modId;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Version version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/** The side this mod declares itself for. */
|
||||
public ModEnv env() {
|
||||
return env;
|
||||
}
|
||||
|
||||
/** True when running on the client. Always true until the loader ships on the server. */
|
||||
public boolean isClient() {
|
||||
return ModEnv.current() == ModEnv.CLIENT;
|
||||
}
|
||||
|
||||
public boolean isServer() {
|
||||
return ModEnv.current() == ModEnv.SERVER;
|
||||
}
|
||||
|
||||
/** Capabilities actually granted, which may be fewer than requested. */
|
||||
public Set<Access> access() {
|
||||
return access;
|
||||
}
|
||||
|
||||
public boolean has(Access capability) {
|
||||
return access.contains(capability);
|
||||
}
|
||||
|
||||
public GameAccess game() {
|
||||
return game;
|
||||
}
|
||||
|
||||
public EventBus events() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public TickScheduler scheduler() {
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
public Log.ModLog log() {
|
||||
return log;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return modId + " " + version + " (" + env + ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* What a mod declares about itself, read from META-INF/rsxmod.properties inside
|
||||
* the mod jar.
|
||||
*
|
||||
* <pre>
|
||||
* id=rsxm-lib
|
||||
* name=RustNotMC Library
|
||||
* version=1.0.0
|
||||
* author=you
|
||||
* license=MIT
|
||||
* env=CLIENT
|
||||
* entrypoint=net.rustnotmc.lib.LibMod
|
||||
* access=hud, text, world, player
|
||||
* depends.core=>=1.0
|
||||
* softdepends.rsxm-hud=*
|
||||
* </pre>
|
||||
*
|
||||
* Properties rather than JSON on purpose: the loader must parse a mod's
|
||||
* metadata before anything else is on the classpath, so it cannot depend on a
|
||||
* JSON library, and it must never fail on a jar whose manifest is malformed.
|
||||
*/
|
||||
public final class ModDescriptor {
|
||||
|
||||
public static final String PATH = "META-INF/rsxmod.properties";
|
||||
public static final int API_VERSION = 1;
|
||||
|
||||
private static final String KEY_ID = "id";
|
||||
private static final String KEY_NAME = "name";
|
||||
private static final String KEY_VERSION = "version";
|
||||
private static final String KEY_DESCRIPTION = "description";
|
||||
private static final String KEY_AUTHOR = "author";
|
||||
private static final String KEY_LICENSE = "license";
|
||||
private static final String KEY_ENV = "env";
|
||||
private static final String KEY_ENTRY = "entrypoint";
|
||||
private static final String KEY_ACCESS = "access";
|
||||
private static final String KEY_API = "api";
|
||||
private static final String PREFIX_DEPENDS = "depends.";
|
||||
private static final String PREFIX_SOFT = "softdepends.";
|
||||
|
||||
private final String id;
|
||||
private final String name;
|
||||
private final Version version;
|
||||
private final String description;
|
||||
private final String author;
|
||||
private final String license;
|
||||
private final ModEnv env;
|
||||
private final String entrypoint;
|
||||
private final int apiVersion;
|
||||
private final Set<Access> access;
|
||||
private final List<Dependency> dependencies;
|
||||
private final List<Dependency> softDependencies;
|
||||
private final File source;
|
||||
|
||||
private ModDescriptor(String id, String name, Version version, String description,
|
||||
String author, String license, ModEnv env, String entrypoint,
|
||||
int apiVersion, Set<Access> access, List<Dependency> dependencies,
|
||||
List<Dependency> softDependencies, File source) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.version = version;
|
||||
this.description = description;
|
||||
this.author = author;
|
||||
this.license = license;
|
||||
this.env = env;
|
||||
this.entrypoint = entrypoint;
|
||||
this.apiVersion = apiVersion;
|
||||
this.access = access;
|
||||
this.dependencies = dependencies;
|
||||
this.softDependencies = softDependencies;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** A hard dependency: if it is missing or unsatisfied, this mod cannot start. */
|
||||
public static final class Dependency {
|
||||
public final String id;
|
||||
public final VersionConstraint constraint;
|
||||
private final String raw;
|
||||
|
||||
Dependency(String id, VersionConstraint constraint, String raw) {
|
||||
this.id = id;
|
||||
this.constraint = constraint;
|
||||
this.raw = raw;
|
||||
}
|
||||
|
||||
public boolean accepts(Version v) {
|
||||
return constraint.accepts(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return raw == null || raw.length() == 0 ? id : id + " " + raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the descriptor out of a mod jar, or null when it carries none. */
|
||||
public static ModDescriptor load(File jar) {
|
||||
ZipFile zip = null;
|
||||
try {
|
||||
zip = new ZipFile(jar);
|
||||
ZipEntry entry = zip.getEntry(PATH);
|
||||
if (entry == null) {
|
||||
// an ordinary library jar sitting in the mods folder
|
||||
return null;
|
||||
}
|
||||
Properties p = new Properties();
|
||||
InputStream in = zip.getInputStream(entry);
|
||||
try {
|
||||
// UTF-8, so a description in any language survives the round trip
|
||||
p.load(new java.io.InputStreamReader(in, "UTF-8"));
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
return from(p, jar);
|
||||
} catch (Throwable t) {
|
||||
Log.warn("cannot read " + PATH + " from " + jar.getName() + ": " + t);
|
||||
return null;
|
||||
} finally {
|
||||
if (zip != null) {
|
||||
try {
|
||||
zip.close();
|
||||
} catch (Throwable ignored) {
|
||||
// read-only archive, nothing to recover
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static ModDescriptor from(Properties p, File source) {
|
||||
String id = trimmed(p.getProperty(KEY_ID));
|
||||
if (id == null || id.length() == 0) {
|
||||
Log.warn("descriptor without '" + KEY_ID + "' in "
|
||||
+ (source == null ? "?" : source.getName()));
|
||||
return null;
|
||||
}
|
||||
if (!isLegalId(id)) {
|
||||
Log.warn("illegal mod id '" + id + "' (want [a-z0-9._-])");
|
||||
return null;
|
||||
}
|
||||
|
||||
String entry = trimmed(p.getProperty(KEY_ENTRY));
|
||||
if (entry == null || entry.length() == 0) {
|
||||
Log.warn("mod " + id + " has no '" + KEY_ENTRY + "'");
|
||||
return null;
|
||||
}
|
||||
|
||||
Version ver = Version.parse(trimmed(p.getProperty(KEY_VERSION)));
|
||||
if (!ver.valid()) {
|
||||
Log.warn("mod " + id + " has no readable version, assuming 0.0.0");
|
||||
ver = Version.parse("0.0.0");
|
||||
}
|
||||
|
||||
int api = parseInt(trimmed(p.getProperty(KEY_API)), API_VERSION);
|
||||
if (api != API_VERSION) {
|
||||
Log.warn("mod " + id + " targets API " + api + ", this loader speaks "
|
||||
+ API_VERSION);
|
||||
}
|
||||
|
||||
Set<Access> access = Access.parseList(p.getProperty(KEY_ACCESS));
|
||||
access.add(Access.CORE);
|
||||
|
||||
return new ModDescriptor(
|
||||
id,
|
||||
or(p.getProperty(KEY_NAME), id),
|
||||
ver,
|
||||
trimmed(p.getProperty(KEY_DESCRIPTION)),
|
||||
trimmed(p.getProperty(KEY_AUTHOR)),
|
||||
trimmed(p.getProperty(KEY_LICENSE)),
|
||||
ModEnv.parse(p.getProperty(KEY_ENV)),
|
||||
entry,
|
||||
api,
|
||||
access,
|
||||
collect(p, PREFIX_DEPENDS),
|
||||
collect(p, PREFIX_SOFT),
|
||||
source);
|
||||
}
|
||||
|
||||
private static List<Dependency> collect(Properties p, String prefix) {
|
||||
Set<Dependency> out = new LinkedHashSet<Dependency>();
|
||||
for (String key : p.stringPropertyNames()) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
String depId = key.substring(prefix.length()).trim();
|
||||
if (depId.length() == 0) {
|
||||
continue;
|
||||
}
|
||||
String raw = trimmed(p.getProperty(key));
|
||||
VersionConstraint c = raw == null || raw.length() == 0 || raw.equals("*")
|
||||
? VersionConstraint.any()
|
||||
: VersionConstraint.parse(raw);
|
||||
out.add(new Dependency(depId, c, raw));
|
||||
}
|
||||
return Collections.unmodifiableList(new ArrayList<Dependency>(out));
|
||||
}
|
||||
|
||||
private static boolean isLegalId(String id) {
|
||||
for (int i = 0; i < id.length(); i++) {
|
||||
char c = id.charAt(i);
|
||||
boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
|| c == '.' || c == '_' || c == '-';
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String trimmed(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String v = s.trim();
|
||||
return v.length() == 0 ? null : v;
|
||||
}
|
||||
|
||||
private static String or(String value, String fallback) {
|
||||
String v = trimmed(value);
|
||||
return v == null ? fallback : v;
|
||||
}
|
||||
|
||||
private static int parseInt(String s, int fallback) {
|
||||
if (s == null) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Version version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public String author() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public String license() {
|
||||
return license;
|
||||
}
|
||||
|
||||
public ModEnv env() {
|
||||
return env;
|
||||
}
|
||||
|
||||
public String entrypoint() {
|
||||
return entrypoint;
|
||||
}
|
||||
|
||||
public int apiVersion() {
|
||||
return apiVersion;
|
||||
}
|
||||
|
||||
/** Capabilities the mod asked for. */
|
||||
public Set<Access> access() {
|
||||
return access;
|
||||
}
|
||||
|
||||
public List<Dependency> dependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
public List<Dependency> softDependencies() {
|
||||
return softDependencies;
|
||||
}
|
||||
|
||||
/** The jar this descriptor came from, null when built by hand in a test. */
|
||||
public File source() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public boolean wants(ModEnv side) {
|
||||
return env.runsOn(side);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return id + " " + version;
|
||||
}
|
||||
|
||||
/** Orders descriptors by id, so a load plan is stable between runs. */
|
||||
public static final Comparator<ModDescriptor> BY_ID = new Comparator<ModDescriptor>() {
|
||||
public int compare(ModDescriptor a, ModDescriptor b) {
|
||||
return a.id.compareTo(b.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
/**
|
||||
* Which side of the game a mod is meant to run on.
|
||||
*
|
||||
* The loader runs on the client today, but the value is part of the descriptor
|
||||
* from the first release: a mod jar that says BOTH today must not need a format
|
||||
* change tomorrow when the loader moves to the server.
|
||||
*/
|
||||
public enum ModEnv {
|
||||
|
||||
CLIENT,
|
||||
SERVER,
|
||||
BOTH;
|
||||
|
||||
/** True when a mod declaring this env should load on the given side. */
|
||||
public boolean runsOn(ModEnv side) {
|
||||
if (side == null) {
|
||||
return false;
|
||||
}
|
||||
return this == BOTH || this == side;
|
||||
}
|
||||
|
||||
/** The side we are running on right now. */
|
||||
public static ModEnv current() {
|
||||
return CLIENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the descriptor value. Unreadable input is treated as BOTH rather
|
||||
* than rejected: refusing to start because of one bad character is worse
|
||||
* than loading a mod that may then find itself unusable.
|
||||
*/
|
||||
public static ModEnv parse(String raw) {
|
||||
if (raw == null) {
|
||||
return BOTH;
|
||||
}
|
||||
String v = raw.trim().toUpperCase();
|
||||
if (v.length() == 0) {
|
||||
return BOTH;
|
||||
}
|
||||
if (v.equals("CLIENT") || v.equals("CLIENT_ONLY") || v.equals("CLIENTONLY")) {
|
||||
return CLIENT;
|
||||
}
|
||||
if (v.equals("SERVER") || v.equals("SERVER_ONLY") || v.equals("SERVERONLY")) {
|
||||
return SERVER;
|
||||
}
|
||||
return BOTH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import net.rustnotmc.api.RustNotMcMod;
|
||||
|
||||
/**
|
||||
* Finds mods, orders them, and drives their lifecycle.
|
||||
*
|
||||
* The load order is decided by dependencies, not by filename order: a mod that
|
||||
* depends on a library must start after it and stop before it. Ordering is a
|
||||
* depth-first walk over the dependency graph with cycle detection, so a broken
|
||||
* set of mods produces a readable error instead of a stack overflow.
|
||||
*
|
||||
* Shutdown runs in reverse. That is not symmetry for its own sake: a mod that
|
||||
* holds a reference to another mod's API object must not find it already gone.
|
||||
*/
|
||||
public final class ModLoader {
|
||||
|
||||
private final File modsDir;
|
||||
private final EventBus events = new EventBus();
|
||||
private final TickScheduler scheduler = new TickScheduler();
|
||||
private final Map<String, ModDescriptor> descriptors = new LinkedHashMap<String, ModDescriptor>();
|
||||
private final Map<String, ModContainer> loaded = new LinkedHashMap<String, ModContainer>();
|
||||
private final List<ModDescriptor> order = new ArrayList<ModDescriptor>();
|
||||
private final List<String> problems = new ArrayList<String>();
|
||||
private final TickEvent tickEvent = new TickEvent();
|
||||
|
||||
public ModLoader(File modsDir) {
|
||||
this.modsDir = modsDir;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- discovery
|
||||
|
||||
/** Reads every jar in the directory. A jar without a descriptor is skipped. */
|
||||
public int discover() {
|
||||
descriptors.clear();
|
||||
problems.clear();
|
||||
if (modsDir == null || !modsDir.isDirectory()) {
|
||||
Log.warn("no mods directory at " + modsDir);
|
||||
return 0;
|
||||
}
|
||||
File[] jars = modsDir.listFiles();
|
||||
if (jars == null) {
|
||||
return 0;
|
||||
}
|
||||
Arrays.sort(jars);
|
||||
int found = 0;
|
||||
for (int i = 0; i < jars.length; i++) {
|
||||
File f = jars[i];
|
||||
if (!f.isFile() || !f.getName().toLowerCase().endsWith(".jar")) {
|
||||
continue;
|
||||
}
|
||||
ModDescriptor d = ModDescriptor.load(f);
|
||||
if (d == null) {
|
||||
continue;
|
||||
}
|
||||
if (!d.wants(ModEnv.current())) {
|
||||
Log.info("skipping " + d.id() + ": env=" + d.env()
|
||||
+ " does not include " + ModEnv.current());
|
||||
continue;
|
||||
}
|
||||
ModDescriptor clash = descriptors.get(d.id());
|
||||
if (clash != null) {
|
||||
problems.add("duplicate mod id '" + d.id() + "': "
|
||||
+ clash.source().getName() + " and " + f.getName()
|
||||
+ "; the higher version wins");
|
||||
Version lower = d.version().atLeast(clash.version()) ? clash.version() : d.version();
|
||||
if (d.version().atLeast(lower)) {
|
||||
descriptors.put(d.id(), d);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
descriptors.put(d.id(), d);
|
||||
found++;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ resolution
|
||||
|
||||
/**
|
||||
* Checks every hard dependency and produces the load order.
|
||||
*
|
||||
* @return true when the whole set can be loaded.
|
||||
*/
|
||||
public boolean resolve() {
|
||||
order.clear();
|
||||
problems.clear();
|
||||
|
||||
for (ModDescriptor d : descriptors.values()) {
|
||||
List<ModDescriptor.Dependency> deps = d.dependencies();
|
||||
for (int i = 0; i < deps.size(); i++) {
|
||||
ModDescriptor.Dependency dep = deps.get(i);
|
||||
ModDescriptor target = descriptors.get(dep.id);
|
||||
if (target == null) {
|
||||
problems.add(d.id() + " needs " + dep + " which is not installed");
|
||||
} else if (!dep.accepts(target.version())) {
|
||||
problems.add(d.id() + " needs " + dep + " but " + target.id()
|
||||
+ " " + target.version() + " is installed");
|
||||
}
|
||||
}
|
||||
List<ModDescriptor.Dependency> soft = d.softDependencies();
|
||||
for (int i = 0; i < soft.size(); i++) {
|
||||
ModDescriptor target = descriptors.get(soft.get(i).id);
|
||||
if (target != null && !soft.get(i).accepts(target.version())) {
|
||||
problems.add(d.id() + " prefers " + soft.get(i) + " but "
|
||||
+ target.id() + " " + target.version() + " is installed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!problems.isEmpty()) {
|
||||
for (int i = 0; i < problems.size(); i++) {
|
||||
Log.error("dependency: " + problems.get(i));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Set<String> done = new HashSet<String>();
|
||||
Set<String> visiting = new HashSet<String>();
|
||||
List<ModDescriptor> sorted = new ArrayList<ModDescriptor>();
|
||||
List<ModDescriptor> byId = new ArrayList<ModDescriptor>(descriptors.values());
|
||||
Collections.sort(byId, ModDescriptor.BY_ID);
|
||||
|
||||
for (int i = 0; i < byId.size(); i++) {
|
||||
if (!visit(byId.get(i), done, visiting, sorted, new ArrayList<String>())) {
|
||||
for (int p = 0; p < problems.size(); p++) {
|
||||
Log.error("dependency: " + problems.get(p));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
order.addAll(sorted);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean visit(ModDescriptor d, Set<String> done, Set<String> visiting,
|
||||
List<ModDescriptor> sorted, List<String> trail) {
|
||||
if (done.contains(d.id())) {
|
||||
return true;
|
||||
}
|
||||
if (visiting.contains(d.id())) {
|
||||
StringBuilder sb = new StringBuilder("dependency cycle: ");
|
||||
for (int i = 0; i < trail.size(); i++) {
|
||||
sb.append(trail.get(i)).append(" -> ");
|
||||
}
|
||||
sb.append(d.id());
|
||||
problems.add(sb.toString());
|
||||
return false;
|
||||
}
|
||||
visiting.add(d.id());
|
||||
trail.add(d.id());
|
||||
|
||||
// hard dependencies first, then soft ones that happen to be installed
|
||||
List<ModDescriptor.Dependency> all = new ArrayList<ModDescriptor.Dependency>(
|
||||
d.dependencies());
|
||||
all.addAll(d.softDependencies());
|
||||
for (int i = 0; i < all.size(); i++) {
|
||||
ModDescriptor target = descriptors.get(all.get(i).id);
|
||||
if (target != null && !visit(target, done, visiting, sorted, trail)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
trail.remove(trail.size() - 1);
|
||||
visiting.remove(d.id());
|
||||
done.add(d.id());
|
||||
sorted.add(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- loading
|
||||
|
||||
/**
|
||||
* The mod currently being constructed, for the duration of its onConstruct.
|
||||
*
|
||||
* The client needs this to record which mod claimed a command name, and it
|
||||
* cannot ask: a mod's name is known here and nowhere else the game can see.
|
||||
* It lives on the loader side precisely so the game side can read it without
|
||||
* the loader ever depending on the game.
|
||||
*/
|
||||
public static final ThreadLocal<String> REGISTERING = new ThreadLocal<String>();
|
||||
|
||||
/** Instantiates and starts every resolved mod, in dependency order. */
|
||||
public int loadAll() {
|
||||
int started = 0;
|
||||
for (int i = 0; i < order.size(); i++) {
|
||||
ModDescriptor d = order.get(i);
|
||||
ModContainer c = create(d);
|
||||
if (c == null) {
|
||||
continue;
|
||||
}
|
||||
loaded.put(d.id(), c);
|
||||
if (c.invoke("onLoad", "load")) {
|
||||
c.state(ModState.LOADED);
|
||||
started++;
|
||||
} else {
|
||||
c.state(ModState.FAILED);
|
||||
}
|
||||
}
|
||||
// only once everything is constructed does anything start doing work,
|
||||
// so a mod can rely on every dependency's API already being live
|
||||
for (int i = 0; i < order.size(); i++) {
|
||||
ModContainer c = loaded.get(order.get(i).id());
|
||||
if (c == null || c.state() == ModState.FAILED) {
|
||||
continue;
|
||||
}
|
||||
if (c.invoke("onEnable", "enable")) {
|
||||
c.state(ModState.ENABLED);
|
||||
} else {
|
||||
c.state(ModState.FAILED);
|
||||
}
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
private ModContainer create(ModDescriptor d) {
|
||||
File jar = d.source();
|
||||
if (jar == null) {
|
||||
Log.warn("mod " + d.id() + " has no jar, cannot load");
|
||||
return null;
|
||||
}
|
||||
URLClassLoader cl = null;
|
||||
try {
|
||||
List<URL> urls = new ArrayList<URL>();
|
||||
urls.add(jar.toURI().toURL());
|
||||
File libs = new File(modsDir, d.id() + "-libs");
|
||||
if (libs.isDirectory()) {
|
||||
File[] extra = libs.listFiles();
|
||||
if (extra != null) {
|
||||
Arrays.sort(extra);
|
||||
for (int i = 0; i < extra.length; i++) {
|
||||
if (extra[i].isFile() && extra[i].getName().endsWith(".jar")) {
|
||||
urls.add(extra[i].toURI().toURL());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// the parent is the loader's own loader, so a mod sees the API and the
|
||||
// game's classes, and never a second copy of the scheduler
|
||||
cl = new URLClassLoader(urls.toArray(new URL[0]), ModLoader.class.getClassLoader());
|
||||
|
||||
Class<?> entry = cl.loadClass(d.entrypoint());
|
||||
if (!RustNotMcMod.class.isAssignableFrom(entry)) {
|
||||
Log.error("mod " + d.id() + ": " + d.entrypoint()
|
||||
+ " does not implement " + RustNotMcMod.class.getName());
|
||||
closeLoader(cl);
|
||||
return null;
|
||||
}
|
||||
Object instance = entry.getDeclaredConstructor().newInstance();
|
||||
Set<Access> granted = grant(d);
|
||||
// each mod gets its own folder for anything it needs to persist
|
||||
File modDir = modsDir == null ? null : new File(modsDir, d.id());
|
||||
ModContext ctx = new ModContext(d, granted, events, scheduler, modDir);
|
||||
ModContainer container = new ModContainer(d, jar, cl, instance, ctx);
|
||||
container.state(ModState.RESOLVED);
|
||||
REGISTERING.set(d.id());
|
||||
try {
|
||||
((RustNotMcMod) instance).onConstruct(new net.rustnotmc.api.Rsx(ctx));
|
||||
} finally {
|
||||
REGISTERING.remove();
|
||||
}
|
||||
container.state(ModState.CONSTRUCTED);
|
||||
return container;
|
||||
} catch (Throwable t) {
|
||||
Log.error("mod " + d.id() + " cannot be constructed", t);
|
||||
closeLoader(cl);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what a mod actually gets.
|
||||
*
|
||||
* Everything it asks for is granted except REFLECTION, which stays withheld
|
||||
* unless the operator lists it in the loader config.
|
||||
*/
|
||||
private Set<Access> grant(ModDescriptor d) {
|
||||
Set<Access> granted = java.util.EnumSet.noneOf(Access.class);
|
||||
for (Access a : d.access()) {
|
||||
if (a == Access.REFLECTION) {
|
||||
if (ReflectionConsent.isGranted(d.id())) {
|
||||
granted.add(a);
|
||||
Log.warn("mod " + d.id() + " has raw reflection, granted by config");
|
||||
} else {
|
||||
Log.warn("mod " + d.id() + " asked for raw reflection and did not get it");
|
||||
}
|
||||
} else {
|
||||
granted.add(a);
|
||||
}
|
||||
}
|
||||
granted.add(Access.CORE);
|
||||
return granted;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- shutdown
|
||||
|
||||
/** Stops every mod in reverse load order. */
|
||||
public void disableAll() {
|
||||
for (int i = order.size() - 1; i >= 0; i--) {
|
||||
ModContainer c = loaded.get(order.get(i).id());
|
||||
if (c != null && c.state() == ModState.ENABLED) {
|
||||
if (c.invoke("onDisable", "disable")) {
|
||||
c.state(ModState.DISABLED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases class loaders after {@link #disableAll()}. */
|
||||
public void unloadAll() {
|
||||
disableAll();
|
||||
for (int i = order.size() - 1; i >= 0; i--) {
|
||||
ModContainer c = loaded.get(order.get(i).id());
|
||||
if (c == null || c.state().isDead()) {
|
||||
continue;
|
||||
}
|
||||
c.invoke("onUnload", "unload");
|
||||
c.state(ModState.UNLOADED);
|
||||
closeLoader(c.classLoader());
|
||||
}
|
||||
events.clear();
|
||||
scheduler.shutdown();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tick
|
||||
|
||||
/** Driven by the game every tick. */
|
||||
public void tick() {
|
||||
scheduler.tick();
|
||||
tickEvent.tick = scheduler.tickCount();
|
||||
events.post(tickEvent);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lookup
|
||||
|
||||
public EventBus events() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public TickScheduler scheduler() {
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
public List<ModContainer> mods() {
|
||||
List<ModContainer> out = new ArrayList<ModContainer>();
|
||||
for (int i = 0; i < order.size(); i++) {
|
||||
ModContainer c = loaded.get(order.get(i).id());
|
||||
if (c != null) {
|
||||
out.add(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public ModContainer mod(String id) {
|
||||
return loaded.get(id);
|
||||
}
|
||||
|
||||
public List<String> problems() {
|
||||
return Collections.unmodifiableList(problems);
|
||||
}
|
||||
|
||||
public List<String> loadOrder() {
|
||||
List<String> out = new ArrayList<String>(order.size());
|
||||
for (int i = 0; i < order.size(); i++) {
|
||||
out.add(order.get(i).id());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public int count() {
|
||||
return loaded.size();
|
||||
}
|
||||
|
||||
private static void close(Closeable c) {
|
||||
if (c == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
c.close();
|
||||
} catch (Throwable ignored) {
|
||||
// nothing useful to do while shutting a class loader down
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeLoader(ClassLoader c) {
|
||||
if (c instanceof Closeable) {
|
||||
close((Closeable) c);
|
||||
}
|
||||
}
|
||||
|
||||
/** The event every mod can subscribe to without depending on anything else. */
|
||||
public static final class TickEvent {
|
||||
long tick;
|
||||
|
||||
public long tick() {
|
||||
return tick;
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator consent for the one capability that is never automatic. */
|
||||
public static final class ReflectionConsent {
|
||||
private static final Set<String> GRANTED =
|
||||
Collections.synchronizedSet(new HashSet<String>());
|
||||
|
||||
private ReflectionConsent() {
|
||||
}
|
||||
|
||||
public static void grant(String modId) {
|
||||
GRANTED.add(modId);
|
||||
}
|
||||
|
||||
public static void revoke(String modId) {
|
||||
GRANTED.remove(modId);
|
||||
}
|
||||
|
||||
public static boolean isGranted(String modId) {
|
||||
return GRANTED.contains(modId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
/**
|
||||
* Where a mod is in its lifecycle.
|
||||
*
|
||||
* The loader refuses to move a mod backwards. A mod that failed during
|
||||
* construction never reaches CONSTRUCTED, so nothing later mistakes it for a
|
||||
* running mod.
|
||||
*/
|
||||
public enum ModState {
|
||||
|
||||
/** Descriptor read, nothing done yet. */
|
||||
DISCOVERED,
|
||||
|
||||
/** Dependencies satisfied and load order decided. */
|
||||
RESOLVED,
|
||||
|
||||
/** Entry class instantiated, onConstruct called. */
|
||||
CONSTRUCTED,
|
||||
|
||||
/** onLoad called; the mod may register things. */
|
||||
LOADED,
|
||||
|
||||
/** onEnable called; the mod is doing its work. */
|
||||
ENABLED,
|
||||
|
||||
/** onDisable called; stopped but still loaded, so it can be re-enabled. */
|
||||
DISABLED,
|
||||
|
||||
/** onUnload called; the mod is gone. */
|
||||
UNLOADED,
|
||||
|
||||
/** Something went wrong; the mod is not running and will not be retried. */
|
||||
FAILED;
|
||||
|
||||
public boolean isRunning() {
|
||||
return this == ENABLED;
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return this == LOADED || this == ENABLED || this == DISABLED;
|
||||
}
|
||||
|
||||
public boolean isDead() {
|
||||
return this == UNLOADED || this == FAILED;
|
||||
}
|
||||
|
||||
public boolean atLeast(ModState s) {
|
||||
return ordinal() >= s.ordinal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Runs mod work on the game thread.
|
||||
*
|
||||
* Three ways in, because they fail differently:
|
||||
*
|
||||
* post next tick, always
|
||||
* everyTicks counted in game ticks, never drifts and never bursts after a lag
|
||||
* every wall clock, for things that do not care about the game
|
||||
*
|
||||
* Wall-clock work never touches game state directly: the timer thread only
|
||||
* posts, and the body runs later on the game thread.
|
||||
*
|
||||
* Every task carries the mod that asked for it, so a failure names a mod and a
|
||||
* mod's error never reaches another mod's task.
|
||||
*/
|
||||
public final class TickScheduler {
|
||||
|
||||
/** Consecutive throws before a task is dropped, so a broken mod cannot flood the log. */
|
||||
private static final int MAX_FAILURES = 5;
|
||||
|
||||
/** Handle to a registered task. */
|
||||
public static final class Task {
|
||||
private final TickScheduler scheduler;
|
||||
private final String mod;
|
||||
private final Runnable body;
|
||||
private final int every;
|
||||
private volatile boolean cancelled;
|
||||
private volatile boolean dropped;
|
||||
private long next;
|
||||
private int failures;
|
||||
ScheduledFuture<?> future;
|
||||
|
||||
Task(TickScheduler scheduler, String mod, Runnable body, int every) {
|
||||
this.scheduler = scheduler;
|
||||
this.mod = mod;
|
||||
this.body = body;
|
||||
this.every = every;
|
||||
}
|
||||
|
||||
/** Stops the task. Safe from inside the task itself. */
|
||||
public void cancel() {
|
||||
cancelled = true;
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
}
|
||||
scheduler.forget(this);
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return !cancelled && !dropped;
|
||||
}
|
||||
|
||||
public String mod() {
|
||||
return mod;
|
||||
}
|
||||
}
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final List<Task> tasks = new ArrayList<Task>();
|
||||
|
||||
/** Reused across ticks: the old code allocated a list every single tick. */
|
||||
private final List<Task> due = new ArrayList<Task>();
|
||||
|
||||
private long tick;
|
||||
private boolean running;
|
||||
|
||||
private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(
|
||||
r -> {
|
||||
Thread t = new Thread(r, "rsx-mod-timer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/** Runs the task once on the next game tick. */
|
||||
public Task post(String mod, Runnable body) {
|
||||
return add(mod, body, 1, 1);
|
||||
}
|
||||
|
||||
/** Runs the task on the next game tick, then stops. */
|
||||
public Task once(String mod, Runnable body) {
|
||||
return add(mod, body, 1, -1);
|
||||
}
|
||||
|
||||
/** Runs the task every N game ticks. */
|
||||
public Task everyTicks(String mod, int n, Runnable body) {
|
||||
return add(mod, body, Math.max(1, n), Math.max(1, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the task on a wall-clock period.
|
||||
*
|
||||
* The timer thread only posts the task; the body still runs on the game
|
||||
* thread, so touching game state here is legal.
|
||||
*/
|
||||
public Task every(String mod, long millis, final Runnable body) {
|
||||
final Task t = add(mod, body, 0, 0);
|
||||
long period = Math.max(1L, millis);
|
||||
try {
|
||||
t.future = timer.scheduleAtFixedRate(new Runnable() {
|
||||
public void run() {
|
||||
// re-post on every period, so the task goes through the same
|
||||
// coalescing and failure handling as everything else
|
||||
synchronized (lock) {
|
||||
if (t.cancelled) {
|
||||
return;
|
||||
}
|
||||
t.next = 0;
|
||||
}
|
||||
enqueue(t);
|
||||
}
|
||||
}, period, period, TimeUnit.MILLISECONDS);
|
||||
} catch (Throwable t2) {
|
||||
Log.error("cannot schedule task for " + mod, t2);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private Task add(String mod, Runnable body, int every, int initialNext) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
Task t = new Task(this, mod == null ? "?" : mod, body, every);
|
||||
t.next = tick + Math.max(0, initialNext);
|
||||
synchronized (lock) {
|
||||
tasks.add(t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Puts a task at the front of the queue for the coming tick. */
|
||||
private void enqueue(Task t) {
|
||||
synchronized (lock) {
|
||||
if (t.cancelled || t.dropped) {
|
||||
return;
|
||||
}
|
||||
if (tasks.indexOf(t) < 0) {
|
||||
tasks.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void forget(Task t) {
|
||||
synchronized (lock) {
|
||||
tasks.remove(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** The game calls this once per tick. */
|
||||
public void tick() {
|
||||
List<Task> batch;
|
||||
synchronized (lock) {
|
||||
tick++;
|
||||
due.clear();
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
Task t = tasks.get(i);
|
||||
if (t.cancelled || t.dropped || t.every == 0) {
|
||||
continue;
|
||||
}
|
||||
if (tick < t.next) {
|
||||
continue;
|
||||
}
|
||||
t.next = tick + t.every;
|
||||
due.add(t);
|
||||
}
|
||||
if (due.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
batch = new ArrayList<Task>(due);
|
||||
due.clear();
|
||||
}
|
||||
for (int i = 0; i < batch.size(); i++) {
|
||||
run(batch.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Current game tick, counted by the scheduler since it started. */
|
||||
public long tickCount() {
|
||||
synchronized (lock) {
|
||||
return tick;
|
||||
}
|
||||
}
|
||||
|
||||
private void run(Task t) {
|
||||
if (t.cancelled || t.dropped) {
|
||||
return;
|
||||
}
|
||||
boolean reentrant;
|
||||
synchronized (lock) {
|
||||
reentrant = running;
|
||||
}
|
||||
if (reentrant) {
|
||||
Log.warn("task of " + t.mod + " posted from inside a tick, running now");
|
||||
}
|
||||
try {
|
||||
t.body.run();
|
||||
synchronized (lock) {
|
||||
t.failures = 0;
|
||||
}
|
||||
if (t.every < 0) {
|
||||
forget(t);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
int n;
|
||||
synchronized (lock) {
|
||||
n = ++t.failures;
|
||||
if (n >= MAX_FAILURES) {
|
||||
t.dropped = true;
|
||||
}
|
||||
}
|
||||
Log.error("task of " + t.mod + " failed", e);
|
||||
if (n >= MAX_FAILURES) {
|
||||
Log.error("task of " + t.mod + " failed " + n
|
||||
+ " times in a row, dropping it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of live tasks, for diagnostics. */
|
||||
public int size() {
|
||||
synchronized (lock) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
Task t = tasks.get(i);
|
||||
if (!t.cancelled && !t.dropped) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
timer.shutdownNow();
|
||||
synchronized (lock) {
|
||||
tasks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
/**
|
||||
* A dotted version with an optional pre-release tag, compared segment by segment.
|
||||
*
|
||||
* 1.0 < 1.0.1
|
||||
* 1.2.3 == 1.2.3
|
||||
* 1.2.3-beta.1 < 1.2.3 (a pre-release sorts below its release)
|
||||
*
|
||||
* Missing trailing segments count as zero, so 1.2 and 1.2.0 are the same version.
|
||||
* An unparseable version never throws; it degrades to 0.0.0 and the comparison
|
||||
* result is reported through {@link #valid()}.
|
||||
*/
|
||||
public final class Version implements Comparable<Version> {
|
||||
|
||||
public static final Version ZERO = new Version(new int[0], "", true);
|
||||
|
||||
private final int[] parts;
|
||||
private final String tag;
|
||||
private final boolean valid;
|
||||
|
||||
private Version(int[] parts, String tag, boolean valid) {
|
||||
this.parts = parts;
|
||||
this.tag = tag;
|
||||
this.valid = valid;
|
||||
}
|
||||
|
||||
/** False when the input could not be parsed at all. */
|
||||
public boolean valid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public static Version parse(String raw) {
|
||||
if (raw == null) {
|
||||
return new Version(new int[0], "", false);
|
||||
}
|
||||
String v = raw.trim();
|
||||
if (v.length() == 0) {
|
||||
return new Version(new int[0], "", false);
|
||||
}
|
||||
if (v.startsWith("v") || v.startsWith("V")) {
|
||||
v = v.substring(1);
|
||||
}
|
||||
|
||||
String tag = "";
|
||||
int cut = indexOfTag(v);
|
||||
if (cut >= 0) {
|
||||
tag = v.substring(cut + 1);
|
||||
v = v.substring(0, cut);
|
||||
}
|
||||
|
||||
String[] rawParts = v.split("\\.");
|
||||
int[] out = new int[rawParts.length];
|
||||
for (int i = 0; i < rawParts.length; i++) {
|
||||
int n = 0;
|
||||
String p = rawParts[i];
|
||||
if (p.length() == 0) {
|
||||
return new Version(new int[0], "", false);
|
||||
}
|
||||
for (int c = 0; c < p.length(); c++) {
|
||||
char ch = p.charAt(c);
|
||||
if (ch < '0' || ch > '9') {
|
||||
return new Version(new int[0], "", false);
|
||||
}
|
||||
n = n * 10 + (ch - '0');
|
||||
}
|
||||
out[i] = n;
|
||||
}
|
||||
return new Version(out, tag, true);
|
||||
}
|
||||
|
||||
private static int indexOfTag(String v) {
|
||||
for (int i = 0; i < v.length(); i++) {
|
||||
char ch = v.charAt(i);
|
||||
boolean separator = ch == '-' || ch == '+' || ch == '_';
|
||||
if (separator) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int compareTo(Version o) {
|
||||
int n = Math.max(parts.length, o.parts.length);
|
||||
for (int i = 0; i < n; i++) {
|
||||
int a = i < parts.length ? parts[i] : 0;
|
||||
int b = i < o.parts.length ? o.parts[i] : 0;
|
||||
if (a != b) {
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
}
|
||||
if (tag.length() == 0 && o.tag.length() == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (tag.length() == 0) {
|
||||
return 1;
|
||||
}
|
||||
if (o.tag.length() == 0) {
|
||||
return -1;
|
||||
}
|
||||
return tag.compareTo(o.tag);
|
||||
}
|
||||
|
||||
public boolean atLeast(Version o) {
|
||||
return compareTo(o) >= 0;
|
||||
}
|
||||
|
||||
public boolean below(Version o) {
|
||||
return compareTo(o) < 0;
|
||||
}
|
||||
|
||||
public boolean same(Version o) {
|
||||
return compareTo(o) == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return o instanceof Version && compareTo((Version) o) == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int h = 0;
|
||||
for (int p : parts) {
|
||||
h = h * 31 + p;
|
||||
}
|
||||
return h * 31 + tag.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
if (!valid) {
|
||||
return "invalid";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('.');
|
||||
}
|
||||
sb.append(parts[i]);
|
||||
}
|
||||
if (tag.length() > 0) {
|
||||
sb.append('-').append(tag);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package net.rustnotmc.loader;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A version requirement, used by dependencies.
|
||||
*
|
||||
* Accepts what mod authors actually write:
|
||||
*
|
||||
* * any version
|
||||
* 1.2.3 exactly this
|
||||
* >=1.0 at least this (also >, <=, <)
|
||||
* 1.2.x 1.2.0 up to but not including 1.3.0
|
||||
* >=1.0,<2.0 a range, all parts must hold
|
||||
*
|
||||
* A version that cannot be parsed never matches anything. Silently accepting a
|
||||
* broken requirement would let a mod load against an API it was never written for.
|
||||
*/
|
||||
public final class VersionConstraint {
|
||||
|
||||
private static final int EQ = 0;
|
||||
private static final int GE = 1;
|
||||
private static final int GT = 2;
|
||||
private static final int LE = 3;
|
||||
private static final int LT = 4;
|
||||
|
||||
private static final VersionConstraint ANY = new VersionConstraint(null);
|
||||
|
||||
private final Term[] terms;
|
||||
|
||||
private VersionConstraint(Term[] terms) {
|
||||
this.terms = terms;
|
||||
}
|
||||
|
||||
public static VersionConstraint any() {
|
||||
return ANY;
|
||||
}
|
||||
|
||||
public static VersionConstraint parse(String raw) {
|
||||
if (raw == null) {
|
||||
return ANY;
|
||||
}
|
||||
String v = raw.trim();
|
||||
if (v.length() == 0 || v.equals("*")) {
|
||||
return ANY;
|
||||
}
|
||||
|
||||
List<Term> out = new ArrayList<Term>();
|
||||
for (String part : v.split(",")) {
|
||||
Term t = parseOne(part.trim());
|
||||
if (t == null) {
|
||||
return new VersionConstraint(new Term[0]);
|
||||
}
|
||||
out.add(t);
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
return new VersionConstraint(new Term[0]);
|
||||
}
|
||||
return new VersionConstraint(out.toArray(new Term[0]));
|
||||
}
|
||||
|
||||
private static Term parseOne(String s) {
|
||||
if (s.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
int op = EQ;
|
||||
if (s.startsWith(">=")) {
|
||||
op = GE;
|
||||
s = s.substring(2);
|
||||
} else if (s.startsWith("<=")) {
|
||||
op = LE;
|
||||
s = s.substring(2);
|
||||
} else if (s.startsWith(">")) {
|
||||
op = GT;
|
||||
s = s.substring(1);
|
||||
} else if (s.startsWith("<")) {
|
||||
op = LT;
|
||||
s = s.substring(1);
|
||||
} else if (s.startsWith("=")) {
|
||||
s = s.substring(1);
|
||||
}
|
||||
s = s.trim();
|
||||
if (s.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int star = indexOfWildcard(s);
|
||||
if (star >= 0) {
|
||||
// 1.2.x becomes >=1.2.0 and <1.3.0, which the compare already knows
|
||||
String prefix = s.substring(0, star);
|
||||
while (prefix.endsWith(".")) {
|
||||
prefix = prefix.substring(0, prefix.length() - 1);
|
||||
}
|
||||
if (prefix.length() == 0) {
|
||||
return new Term(GE, Version.ZERO, null);
|
||||
}
|
||||
Version low = Version.parse(prefix);
|
||||
if (!low.valid()) {
|
||||
return null;
|
||||
}
|
||||
Version high = bumpLast(prefix);
|
||||
return new Term(0, low, high);
|
||||
}
|
||||
|
||||
Version exact = Version.parse(s);
|
||||
if (!exact.valid()) {
|
||||
return null;
|
||||
}
|
||||
return new Term(op, exact, null);
|
||||
}
|
||||
|
||||
private static int indexOfWildcard(String s) {
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char ch = s.charAt(i);
|
||||
if (ch == 'x' || ch == 'X' || ch == '*') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first version above a wildcard prefix.
|
||||
*
|
||||
* "1.2" becomes 1.3.0, not 2.0.0: a wildcard covers the rest of that segment
|
||||
* only, so 1.2.x must reject 1.3.0 and accept 1.2.99.
|
||||
*/
|
||||
private static Version bumpLast(String prefix) {
|
||||
String[] parts = prefix.split("\\.");
|
||||
int[] seg = new int[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
try {
|
||||
seg[i] = Integer.parseInt(parts[i]);
|
||||
} catch (NumberFormatException e) {
|
||||
seg[i] = 0;
|
||||
}
|
||||
}
|
||||
seg[seg.length - 1]++;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < seg.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('.');
|
||||
}
|
||||
sb.append(seg[i]);
|
||||
}
|
||||
sb.append(".0");
|
||||
return Version.parse(sb.toString());
|
||||
}
|
||||
|
||||
/** True when the constraint accepts the given version. */
|
||||
public boolean accepts(Version v) {
|
||||
if (terms == null) {
|
||||
return true;
|
||||
}
|
||||
if (terms.length == 0) {
|
||||
return false;
|
||||
}
|
||||
if (v == null || !v.valid()) {
|
||||
return false;
|
||||
}
|
||||
for (Term t : terms) {
|
||||
if (!t.holds(v)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static final class Term {
|
||||
final int op;
|
||||
final Version value;
|
||||
final Version high;
|
||||
|
||||
Term(int op, Version value, Version high) {
|
||||
this.op = op;
|
||||
this.value = value;
|
||||
this.high = high;
|
||||
}
|
||||
|
||||
boolean holds(Version v) {
|
||||
if (high != null) {
|
||||
return v.atLeast(value) && v.below(high);
|
||||
}
|
||||
switch (op) {
|
||||
case GE:
|
||||
return v.atLeast(value);
|
||||
case GT:
|
||||
return v.compareTo(value) > 0;
|
||||
case LE:
|
||||
return v.compareTo(value) <= 0;
|
||||
case LT:
|
||||
return v.below(value);
|
||||
default:
|
||||
return v.same(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user