commit 3d429b2a86e51f2198443f35271a226cf9ad8119 Author: SashegDev Date: Sat Oct 17 02:51:13 2026 +0300 rsxm-lib: общая библиотека для модов Загрузчик игры даёт каждому моду свой class loader, поэтому моды не видят друг друга. Но у всех этих загрузчиков родитель — системный, а значит всё, что попало в его class path, доступно каждому моду. Агент подмешивает jar'ы из libs\ именно туда, поэтому библиотека одна на всех, и модам не нужно вшивать её в себя. Первый набор: - json.Json — разборщик и писатель JSON без внешних зависимостей; - config.Config — настройки мода в config\.json, битый файл не роняет игру, а откатывается на значения по умолчанию с записью в лог; - config.Home — где лежит модлоадер, чтобы мод не гадал; - config.Log — логи с именем мода. build.cmd собирает jar и сразу гоняет тесты библиотеки. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cff67d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +*.class diff --git a/README.md b/README.md new file mode 100644 index 0000000..38cf18b --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# rsxm-lib + +Общая библиотека для модов RustexRemake. Лежит в `libs\` рядом с `mods\`, и +агент подмешивает её в системный class path при старте — поэтому моды видят её +без дублирования: один jar, одна копия в памяти. + +Загрузчик игры даёт каждому моду свой class loader, и напрямую моды друг друга +не видят. Но у всех этих загрузчиков родитель — системный, а значит всё, что +в нём, доступно каждому. На этом и построена библиотека. + +## Что внутри + +* `net.rsxm.lib.json.Json` — маленький разборщик и писатель JSON. Без внешних + зависимостей: мод — это jar, который друг бросил в папку, тащить туда ещё и + библиотеку незачем. +* `net.rsxm.lib.config.Config` — настройки мода в `config\.json`. + Значения по умолчанию задаёт сам мод, битый файл не роняет игру, а заменяется + дефолтами с записью в лог. +* `net.rsxm.lib.config.Home` — где вообще лежит модлоадер, чтобы мод не гадал. +* `net.rsxm.lib.config.Log` — логирование с именем мода. + +## Сборка + + build.cmd + +Нужен только JDK. Скрипт собирает `dist\rsxm-lib.jar` и сразу прогоняет тесты +JSON и конфигов. + +## Использование + +Компилируйте мод против `rsx-api.jar` **и** `rsxm-lib.jar`, но не кладите +`rsxm-lib.jar` внутрь мода — он и так на class path: + + javac -cp "rsx-api.jar;rsxm-lib.jar" ... + + Config cfg = Config.of("hud"); + boolean show = cfg.bool("show", true); + cfg.set("x", 8); + cfg.save(); diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..2caba0d --- /dev/null +++ b/build.cmd @@ -0,0 +1,34 @@ +@echo off +rem Builds rsxm-lib: the jar goes to dist\, the tests run right after. +rem Only a JDK is needed. +setlocal +cd /d "%~dp0" + +if not exist "build\classes" mkdir "build\classes" +if not exist "build\testclasses" mkdir "build\testclasses" +if not exist "dist" mkdir "dist" + +echo [1/3] compiling... +dir /s /b src\main\java\*.java > build\sources.txt +dir /s /b src\test\java\*.java > build\testsources.txt +javac -encoding UTF-8 --release 8 -nowarn -cp "libs\rsx-api.jar" -d build\classes @build\sources.txt +if errorlevel 1 goto :fail +javac -encoding UTF-8 --release 8 -nowarn -cp "libs\rsx-api.jar;build\classes" -d build\testclasses @build\testsources.txt +if errorlevel 1 goto :fail + +echo [2/3] packaging rsxm-lib.jar... +if exist "dist\rsxm-lib.jar" del "dist\rsxm-lib.jar" +jar cf "dist\rsxm-lib.jar" -C build\classes . + +echo [3/3] running the library tests... +java -cp "libs\rsx-api.jar;build\classes;build\testclasses" net.rsxm.lib.test.LibTest +if errorlevel 1 goto :fail + +echo. +echo Готово: dist\rsxm-lib.jar +exit /b 0 + +:fail +echo. +echo Сборка не удалась. +exit /b 1 diff --git a/libs/rsx-api.jar b/libs/rsx-api.jar new file mode 100644 index 0000000..6f3fcd3 Binary files /dev/null and b/libs/rsx-api.jar differ diff --git a/src/main/java/net/rsxm/lib/config/Config.java b/src/main/java/net/rsxm/lib/config/Config.java new file mode 100644 index 0000000..d104319 --- /dev/null +++ b/src/main/java/net/rsxm/lib/config/Config.java @@ -0,0 +1,237 @@ +package net.rsxm.lib.config; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import net.rsxm.lib.json.Json; + +/** + * A mod's settings, kept in a JSON file under config\. + * + * A mod declares its defaults once, asks for a value by name, and never has to + * think about where the file lives or whether it is readable: a missing or + * broken file falls back to the defaults, is rewritten, and the reason lands in + * the log. + * + * Config cfg = Config.of("hud"); + * boolean show = cfg.bool("show", true); + * cfg.save(); + */ +public final class Config { + + private static final Map LOADED = + new LinkedHashMap(); + + private final String mod; + private final File file; + private final Map values = new LinkedHashMap(); + private boolean dirty; + + private Config(String mod, File file) { + this.mod = mod; + this.file = file; + } + + /** The config of a mod, shared so two calls see the same object. */ + public static synchronized Config of(String mod) { + Config c = LOADED.get(mod); + if (c == null) { + c = new Config(mod, new File(Home.configDir(), mod + ".json")); + c.load(); + LOADED.put(mod, c); + } + return c; + } + + private void load() { + if (!file.isFile()) { + dirty = true; + return; + } + try { + InputStream in = new FileInputStream(file); + try { + ByteBuilder b = new ByteBuilder(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + b.add(buf, n); + } + Object parsed = Json.parse(b.toString("UTF-8")); + if (parsed instanceof Map) { + for (Map.Entry e : ((Map) parsed).entrySet()) { + values.put(String.valueOf(e.getKey()), e.getValue()); + } + } else { + dirty = true; + } + } finally { + in.close(); + } + } catch (Throwable t) { + Log.warn(mod + ": cannot read " + file.getName() + " (" + t + + "), using defaults"); + values.clear(); + dirty = true; + } + } + + /** Write the config back, if anything changed. */ + public synchronized void save() { + if (!dirty) { + return; + } + try { + File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + Log.warn(mod + ": cannot create " + dir); + return; + } + OutputStream out = new FileOutputStream(file); + try { + out.write(Json.write(values).getBytes("UTF-8")); + } finally { + out.close(); + } + dirty = false; + } catch (Throwable t) { + Log.warn(mod + ": cannot write " + file.getName() + ": " + t); + } + } + + /** Reload from disk, dropping unsaved changes. */ + public synchronized void reload() { + values.clear(); + dirty = false; + load(); + } + + public File file() { + return file; + } + + // --------------------------------------------------------------- getters + + public boolean bool(String key, boolean def) { + Object v = values.get(key); + if (v instanceof Boolean) { + return ((Boolean) v).booleanValue(); + } + if (v instanceof String) { + return Boolean.parseBoolean((String) v); + } + return def; + } + + public int integer(String key, int def) { + Object v = number(key); + return v == null ? def : (int) ((Number) v).longValue(); + } + + public long number(String key, long def) { + Object v = number(key); + return v == null ? def : ((Number) v).longValue(); + } + + public double decimal(String key, double def) { + Object v = number(key); + return v == null ? def : ((Number) v).doubleValue(); + } + + public String text(String key, String def) { + Object v = values.get(key); + return v == null ? def : String.valueOf(v); + } + + public List list(String key) { + Object v = values.get(key); + List out = new ArrayList(); + if (v instanceof List) { + for (Object o : (List) v) { + out.add(String.valueOf(o)); + } + } + return out; + } + + private Object number(String key) { + Object v = values.get(key); + if (v instanceof Number) { + return v; + } + if (v instanceof String) { + try { + return Double.valueOf((String) v); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + // --------------------------------------------------------------- setters + + public Config set(String key, boolean value) { + return put(key, Boolean.valueOf(value)); + } + + public Config set(String key, int value) { + return put(key, Integer.valueOf(value)); + } + + public Config set(String key, long value) { + return put(key, Long.valueOf(value)); + } + + public Config set(String key, double value) { + return put(key, Double.valueOf(value)); + } + + public Config set(String key, String value) { + return put(key, value); + } + + public Config set(String key, List value) { + return put(key, new ArrayList(value)); + } + + public Config remove(String key) { + if (values.remove(key) != null) { + dirty = true; + } + return this; + } + + private Config put(String key, Object value) { + Object old = values.put(key, value); + if (old == null || !old.equals(value)) { + dirty = true; + } + return this; + } + + /** Every key currently in the file, defaults included after a save. */ + public synchronized List keys() { + return new ArrayList(values.keySet()); + } + + private static final class ByteBuilder { + private final java.io.ByteArrayOutputStream out = + new java.io.ByteArrayOutputStream(); + + void add(byte[] b, int n) { + out.write(b, 0, n); + } + + String toString(String enc) throws Exception { + return out.toString(enc); + } + } +} diff --git a/src/main/java/net/rsxm/lib/config/Home.java b/src/main/java/net/rsxm/lib/config/Home.java new file mode 100644 index 0000000..1c2bf7c --- /dev/null +++ b/src/main/java/net/rsxm/lib/config/Home.java @@ -0,0 +1,70 @@ +package net.rsxm.lib.config; + +import java.io.File; + +/** + * Where the modloader keeps things, discovered once from the class path. + * + * The bridge jar sits in \bridge\, so its parent is the modloader home. + * A mod that asks "where is my config" should not have to know that, and it + * should not have to care whether the game is running or not. + */ +public final class Home { + + private static File dir; + + private Home() { + } + + public static synchronized File dir() { + if (dir == null) { + dir = discover(); + } + return dir; + } + + /** + * Walk up from where this code lives until a directory looks like a + * modloader home, i.e. it has bridge\ or mods\ next to it. The library is + * normally in \libs\, but the same jar on a class path during a build + * or a test sits somewhere else entirely, so nothing here may assume a + * fixed depth. + */ + private static File discover() { + try { + File self = new File(Home.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + File at = self.isDirectory() ? self : self.getParentFile(); + for (int up = 0; at != null && up < 4; up++) { + if (isHome(at)) { + return at; + } + at = at.getParentFile(); + } + if (!self.isDirectory() && self.getParentFile() != null) { + return self.getParentFile(); + } + return self; + } catch (Throwable ignored) { + return new File(System.getProperty("user.home"), "rsx-mod"); + } + } + + private static boolean isHome(File f) { + return new File(f, "bridge").isDirectory() || new File(f, "mods").isDirectory(); + } + + /** \config, created on demand. */ + public static File configDir() { + File d = new File(dir(), "config"); + if (!d.isDirectory()) { + d.mkdirs(); + } + return d; + } + + /** \mods */ + public static File modsDir() { + return new File(dir(), "mods"); + } +} diff --git a/src/main/java/net/rsxm/lib/config/Log.java b/src/main/java/net/rsxm/lib/config/Log.java new file mode 100644 index 0000000..b19b2aa --- /dev/null +++ b/src/main/java/net/rsxm/lib/config/Log.java @@ -0,0 +1,27 @@ +package net.rsxm.lib.config; + +/** + * Logging for mods, so a mod's messages land in the same bridge log as the + * modloader's own and carry the mod's name. + */ +public final class Log { + + private Log() { + } + + public static void info(String mod, String message) { + net.rustexremake.api.Rsx.info("[" + mod + "] " + message); + } + + public static void warn(String mod, String message) { + net.rustexremake.api.Rsx.warn("[" + mod + "] " + message); + } + + public static void error(String mod, String message, Throwable cause) { + net.rustexremake.api.Rsx.error("[" + mod + "] " + message, cause); + } + + public static void warn(String message) { + net.rustexremake.api.Rsx.warn("[rsxm-lib] " + message); + } +} diff --git a/src/main/java/net/rsxm/lib/json/Json.java b/src/main/java/net/rsxm/lib/json/Json.java new file mode 100644 index 0000000..51f5bb1 --- /dev/null +++ b/src/main/java/net/rsxm/lib/json/Json.java @@ -0,0 +1,283 @@ +package net.rsxm.lib.json; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A very small JSON reader and writer. + * + * Mod configs are plain files next to the mods, and a mod is a jar a friend + * dropped into a folder. That rules out pulling a JSON library into the mod + * classpath, so this parses the subset that actually shows up in a config: + * objects, arrays, strings, numbers, booleans and null. + * + * It is deliberately forgiving: a broken file does not throw into the game, it + * falls back to the defaults and says so once in the log. + */ +public final class Json { + + private Json() { + } + + // ------------------------------------------------------------------ parse + + public static Object parse(String text) { + if (text == null) { + return null; + } + try { + Reader r = new Reader(text); + Object v = r.value(); + r.skip(); + return v; + } catch (Throwable t) { + return null; + } + } + + private static final class Reader { + private final String s; + private int i; + + Reader(String s) { + this.s = s; + } + + void skip() { + while (i < s.length() && Character.isWhitespace(s.charAt(i))) { + i++; + } + } + + Object value() { + skip(); + if (i >= s.length()) { + throw new IllegalStateException("unexpected end"); + } + char c = s.charAt(i); + switch (c) { + case '{': return object(); + case '[': return array(); + case '"': return string(); + case 't': expect("true"); return Boolean.TRUE; + case 'f': expect("false"); return Boolean.FALSE; + case 'n': expect("null"); return null; + default: return number(); + } + } + + private void expect(String word) { + if (!s.startsWith(word, i)) { + throw new IllegalStateException("expected " + word); + } + i += word.length(); + } + + Map object() { + Map out = new LinkedHashMap(); + i++; // { + skip(); + if (i < s.length() && s.charAt(i) == '}') { + i++; + return out; + } + while (true) { + skip(); + String key = string(); + skip(); + if (i >= s.length() || s.charAt(i) != ':') { + throw new IllegalStateException("expected :"); + } + i++; + out.put(key, value()); + skip(); + if (i >= s.length()) { + throw new IllegalStateException("unterminated object"); + } + char c = s.charAt(i++); + if (c == '}') { + return out; + } + if (c != ',') { + throw new IllegalStateException("expected , or }"); + } + } + } + + List array() { + List out = new ArrayList(); + i++; // [ + skip(); + if (i < s.length() && s.charAt(i) == ']') { + i++; + return out; + } + while (true) { + out.add(value()); + skip(); + if (i >= s.length()) { + throw new IllegalStateException("unterminated array"); + } + char c = s.charAt(i++); + if (c == ']') { + return out; + } + if (c != ',') { + throw new IllegalStateException("expected , or ]"); + } + } + } + + String string() { + if (i >= s.length() || s.charAt(i) != '"') { + throw new IllegalStateException("expected string"); + } + i++; + StringBuilder b = new StringBuilder(); + while (i < s.length()) { + char c = s.charAt(i++); + if (c == '"') { + return b.toString(); + } + if (c != '\\') { + b.append(c); + continue; + } + char e = s.charAt(i++); + switch (e) { + case 'n': b.append('\n'); break; + case 't': b.append('\t'); break; + case 'r': b.append('\r'); break; + case 'b': b.append('\b'); break; + case 'f': b.append('\f'); break; + case 'u': + b.append((char) Integer.parseInt( + s.substring(i, i + 4), 16)); + i += 4; + break; + default: b.append(e); + } + } + throw new IllegalStateException("unterminated string"); + } + + Object number() { + int start = i; + while (i < s.length() && "+-.eE0123456789".indexOf(s.charAt(i)) >= 0) { + i++; + } + String raw = s.substring(start, i); + if (raw.isEmpty()) { + throw new IllegalStateException("expected a value at " + start); + } + if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 + && raw.indexOf('E') < 0) { + return Long.valueOf(raw); + } + return Double.valueOf(raw); + } + } + + // ------------------------------------------------------------------ write + + public static String write(Object value) { + StringBuilder b = new StringBuilder(); + write(value, b, 0); + return b.toString(); + } + + private static void write(Object v, StringBuilder b, int depth) { + if (v == null) { + b.append("null"); + } else if (v instanceof Map) { + writeObject((Map) v, b, depth); + } else if (v instanceof List) { + writeArray((List) v, b, depth); + } else if (v instanceof Boolean) { + b.append(v.toString()); + } else if (v instanceof Number) { + writeNumber((Number) v, b); + } else { + quote(String.valueOf(v), b); + } + } + + private static void writeNumber(Number n, StringBuilder b) { + double d = n.doubleValue(); + if (n instanceof Double || n instanceof Float) { + if (d == Math.rint(d) && !Double.isInfinite(d)) { + b.append((long) d).append(".0"); + return; + } + } + b.append(n.toString()); + } + + private static void writeObject(Map m, StringBuilder b, int depth) { + if (m.isEmpty()) { + b.append("{}"); + return; + } + b.append("{\n"); + int n = 0; + for (Map.Entry e : m.entrySet()) { + if (n++ > 0) { + b.append(",\n"); + } + indent(b, depth + 1); + quote(String.valueOf(e.getKey()), b); + b.append(": "); + write(e.getValue(), b, depth + 1); + } + b.append('\n'); + indent(b, depth); + b.append('}'); + } + + private static void writeArray(List l, StringBuilder b, int depth) { + if (l.isEmpty()) { + b.append("[]"); + return; + } + b.append("[\n"); + for (int i = 0; i < l.size(); i++) { + if (i > 0) { + b.append(",\n"); + } + indent(b, depth + 1); + write(l.get(i), b, depth + 1); + } + b.append('\n'); + indent(b, depth); + b.append(']'); + } + + private static void indent(StringBuilder b, int depth) { + for (int i = 0; i < depth; i++) { + b.append(" "); + } + } + + static void quote(String s, StringBuilder b) { + b.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + b.append('"'); + } +} diff --git a/src/test/java/net/rsxm/lib/test/LibTest.java b/src/test/java/net/rsxm/lib/test/LibTest.java new file mode 100644 index 0000000..5c482ed --- /dev/null +++ b/src/test/java/net/rsxm/lib/test/LibTest.java @@ -0,0 +1,115 @@ +package net.rsxm.lib.test; + +import java.io.File; +import java.util.List; + +import net.rsxm.lib.config.Config; +import net.rsxm.lib.config.Home; +import net.rsxm.lib.json.Json; + +/** + * Checks the parts of the library that have nothing to do with the game: the + * JSON codec and the config file. Run it straight from the build, it needs no + * mods and no game. + */ +public final class LibTest { + + private static int failed; + + public static void main(String[] args) { + jsonRoundTrip(); + jsonBroken(); + configRoundTrip(); + home(); + + System.out.println(failed == 0 + ? "all library checks passed" + : failed + " check(s) failed"); + System.exit(failed == 0 ? 0 : 1); + } + + private static void check(String what, boolean ok) { + System.out.println(" " + what + " " + (ok ? "OK" : "FAIL")); + if (!ok) { + failed++; + } + } + + @SuppressWarnings("unchecked") + private static void jsonRoundTrip() { + System.out.println("== json =="); + String src = "{\"a\":1,\"b\":2.5,\"c\":\"текст\",\"d\":true," + + "\"e\":null,\"f\":[1,2,{\"g\":\"h\"}]}"; + Object v = Json.parse(src); + check("parses to a map", v instanceof java.util.Map); + java.util.Map m = (java.util.Map) v; + check("keeps integers", Long.valueOf(1L).equals(m.get("a"))); + check("keeps decimals", Double.valueOf(2.5).equals(m.get("b"))); + check("keeps non-ascii", "текст".equals(m.get("c"))); + check("keeps booleans", Boolean.TRUE.equals(m.get("d"))); + check("keeps null", m.containsKey("e") && m.get("e") == null); + check("keeps arrays", m.get("f") instanceof List); + + String out = Json.write(m); + Object again = Json.parse(out); + check("write then parse is stable", + again instanceof java.util.Map && m.equals(again)); + check("escapes quotes", Json.write("a\"b").equals("\"a\\\"b\"")); + } + + private static void jsonBroken() { + System.out.println("== json, broken input =="); + check("garbage does not throw", Json.parse("{not json") == null); + check("empty does not throw", Json.parse("") == null); + check("null does not throw", Json.parse(null) == null); + } + + private static void configRoundTrip() { + System.out.println("== config =="); + Config c = Config.of("rsxm-test"); + c.set("show", true) + .set("x", 3) + .set("scale", 1.5) + .set("label", "тест") + .set("list", java.util.Arrays.asList("a", "b")); + c.save(); + + File f = c.file(); + check("file exists", f.isFile()); + check("defaults when absent", c.bool("missing", true)); + + // read it back through a fresh object + Config fresh = newFileConfig("rsxm-test"); + check("reads booleans", fresh.bool("show", false)); + check("reads integers", fresh.integer("x", 0) == 3); + check("reads decimals", fresh.decimal("scale", 0d) == 1.5d); + check("reads text", "тест".equals(fresh.text("label", ""))); + check("reads lists", fresh.list("list").size() == 2); + f.delete(); + } + + private static void home() { + System.out.println("== home =="); + File d = Home.dir(); + check("home is a directory", d.isDirectory()); + check("config dir is created", Home.configDir().isDirectory()); + System.out.println(" home: " + d); + } + + /** A second view of the same file, bypassing the shared instance. */ + private static Config newFileConfig(String mod) { + try { + java.lang.reflect.Constructor c = Config.class + .getDeclaredConstructor(String.class, File.class); + c.setAccessible(true); + Config cfg = c.newInstance(mod, new File(Home.configDir(), mod + ".json")); + java.lang.reflect.Method load = Config.class + .getDeclaredMethod("load"); + load.setAccessible(true); + load.invoke(cfg); + return cfg; + } catch (Throwable t) { + throw new IllegalStateException(t); + } + } +}