rsxm-lib: общая библиотека для модов

Загрузчик игры даёт каждому моду свой class loader, поэтому моды не видят
друг друга. Но у всех этих загрузчиков родитель — системный, а значит всё,
что попало в его class path, доступно каждому моду. Агент подмешивает jar'ы
из libs\ именно туда, поэтому библиотека одна на всех, и модам не нужно
вшивать её в себя.

Первый набор:
- json.Json — разборщик и писатель JSON без внешних зависимостей;
- config.Config — настройки мода в config\<mod>.json, битый файл не роняет
  игру, а откатывается на значения по умолчанию с записью в лог;
- config.Home — где лежит модлоадер, чтобы мод не гадал;
- config.Log — логи с именем мода.

build.cmd собирает jar и сразу гоняет тесты библиотеки.
This commit is contained in:
SashegDev
2026-10-17 02:51:13 +03:00
commit 3d429b2a86
9 changed files with 808 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
build/
dist/
*.class
+39
View File
@@ -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\<mod>.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();
+34
View File
@@ -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
BIN
View File
Binary file not shown.
@@ -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<String, Config> LOADED =
new LinkedHashMap<String, Config>();
private final String mod;
private final File file;
private final Map<String, Object> values = new LinkedHashMap<String, Object>();
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<String> list(String key) {
Object v = values.get(key);
List<String> out = new ArrayList<String>();
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<String> value) {
return put(key, new ArrayList<Object>(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<String> keys() {
return new ArrayList<String>(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);
}
}
}
@@ -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 <home>\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 <home>\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();
}
/** <home>\config, created on demand. */
public static File configDir() {
File d = new File(dir(), "config");
if (!d.isDirectory()) {
d.mkdirs();
}
return d;
}
/** <home>\mods */
public static File modsDir() {
return new File(dir(), "mods");
}
}
@@ -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);
}
}
+283
View File
@@ -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<String, Object> object() {
Map<String, Object> out = new LinkedHashMap<String, Object>();
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<Object> array() {
List<Object> out = new ArrayList<Object>();
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('"');
}
}
@@ -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<String, Object> m = (java.util.Map<String, Object>) 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<Config> 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);
}
}
}