Форк клиента собирается из исходников, модзагрузчик встроен в игру

Проект начинается с двух вещей: клиент, который мы действительно собрали, и
загрузчик, который является его частью, а не чем-то рядом.

Форк
- 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:
sasheg dev
2026-10-18 21:31:27 +03:00
commit bdf0ae866a
66 changed files with 8839 additions and 0 deletions
@@ -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=&gt;=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
* &gt;=1.0 at least this (also &gt;, &lt;=, &lt;)
* 1.2.x 1.2.0 up to but not including 1.3.0
* &gt;=1.0,&lt;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);
}
}
}
}