Эталонный мод RustexModding

Собран так, чтобы его читал человек, который пишет первый мод. Показывает
apiVersion, конфиги из rsxm-lib, события, everyTicks, типизированные обёртки
Rsx.Player и Rsx.World, onError и onUnload.

Ни одного обфусцированного имени: мод спрашивает логическими именами, бриф
переводит их в имена текущей сборки. Ни рефлексии, ни чтения файлов игры -
всё это либо не нужно, либо делает бриф.

Компилируется против rsx-api.jar и rsxm-lib.jar, но не содержит ни одного:
библиотека лежит в libs\ и подмешивается в системный class path при старте.
Проверяется просто - если в jar мода нет net/rsxm/, он собран правильно.
This commit is contained in:
SashegDev
2026-10-17 02:55:29 +03:00
commit bfda50ae61
5 changed files with 240 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# сборка
build/
dist/
*.class
# зависимости: их не коммитим, они собираются из модлоадера и rsxm-lib
libs/
+48
View File
@@ -0,0 +1,48 @@
# rsxm-example
Эталонный мод RustexModding. Собери его, положи в `%LOCALAPPDATA%\rsx-mod\mods`
и посмотри лог — он рассчитан на то, чтобы его читал человек, который пишет
первый мод.
build.cmd
Нужен только JDK. Скрипт собирает `build\mods\rsxm-example.jar` и кладёт его в
папку модов; если путь другой, переопредели:
set MODS_DIR=D:\path\to\mods && build.cmd
## Что показывает мод
* `apiVersion()` — версия API, под которую написан мод;
* `Config` из rsxm-lib — настройки в `config\example.json`, значения по
умолчанию задаёт сам мод;
* события `playerJoin`, `playerLeave`, `worldChange`;
* `everyTicks` — периодичность по игровым тикам;
* типизированные обёртки `Rsx.Player` и `Rsx.World`;
* `onError` — что мод делает, когда задача упала;
* `onUnload` — что мод делает на выходе.
## Чего здесь нет - и почему
Ни одного обфусцированного имени: ни класса, ни поля, ни метода. Мод
спрашивает логическими именами, бриф переводит их в имена текущей сборки.
Поэтому мод собирается один раз и переживает очередную реобфускацию игры.
Нет рефлексии, нет чтения файлов игры, нет работы с class loader-ами. Всё
это либо не нужно, либо делает бриф: `Rsx.get()`, `Rsx.call()` и
`Rsx.field()` существуют для редких случаев, когда профиль ещё не знает
член, и пользоваться ими стоит в последнюю очередь.
## Зависимости
Компилируется против двух jar, но не содержит ни одного из них:
* `libs\rsx-api.jar` - API, идёт в составе модлоадера;
* `libs\rsxm-lib.jar` - общая библиотека, лежит в `%LOCALAPPDATA%\rsx-mod\libs`
и подмешивается в системный class path при старте игры.
Загрузчик игры даёт каждому моду свой class loader, поэтому моды не видят друг
друга напрямую - но у всех этих загрузчиков родитель системный, а значит всё,
что в нём, доступно каждому. Поэтому библиотека одна на всех, и вшивать её в
мод не нужно. Проверяется это просто: если в jar мода нет `net/rsxm/`, значит
он собрался правильно.
+38
View File
@@ -0,0 +1,38 @@
@echo off
rem Builds the mod and drops the jar into the client's mods folder.
rem Only a JDK is needed: no Gradle, no network, nothing but javac and jar.
setlocal
cd /d "%~dp0"
if not exist "build\classes" mkdir "build\classes"
if not exist "build\mods" mkdir "build\mods"
set ENTRY=com.example.Diagnostics
rem where the modloader looks for mods; override with set MODS_DIR=...
if "%MODS_DIR%"=="" set MODS_DIR=%LOCALAPPDATA%\rsx-mod\mods
echo [1/3] compiling...
dir /s /b src\main\java\*.java > build\sources.txt
javac -encoding UTF-8 --release 8 -nowarn -cp "libs\rsx-api.jar;libs\rsxm-lib.jar" -d build\classes @build\sources.txt
if errorlevel 1 (
echo.
echo ?????? ?? ???????.
exit /b 1
)
echo [2/3] packaging rsxm-example.jar...
if exist "build\mods\rsxm-example.jar" del "build\mods\rsxm-example.jar"
jar cfm "build\mods\rsxm-example.jar" manifest.txt -C build/classes .
if errorlevel 1 exit /b 1
echo [3/3] installing...
if not exist "%MODS_DIR%" (
echo.
echo ????? ????? ?? ???????: %MODS_DIR%
echo ???????? build\mods\rsxm-example.jar ? ??? ???.
exit /b 0
)
copy /y "build\mods\rsxm-example.jar" "%MODS_DIR%\" >nul
echo.
echo ??????: build\mods\rsxm-example.jar ?????????? ? %MODS_DIR%
+4
View File
@@ -0,0 +1,4 @@
Manifest-Version: 1.0
Rsx-Mod-Entrypoint: com.example.ExampleMod
+143
View File
@@ -0,0 +1,143 @@
package com.example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
import net.rsxm.lib.Rsxm;
import net.rsxm.lib.config.Config;
import net.rsxm.lib.config.Log;
/**
* Эталонный мод RustexModding.
*
* Здесь нет ни одного обфусцированного имени - ни класса, ни поля, ни метода.
* Мод спрашивает логическими именами, а бриф переводит их в имена той сборки,
* которая стоит у игрока. Поэтому мод собирается один раз и продолжает
* работать после того, как разработчик игры переименует всё заново.
*
* Чего здесь тоже нет: рефлексии, обфусцированных имён, class loader-ов,
* чтения файлов игры. Всё, что нужно, лежит в API и в rsxm-lib.
*
* Порядок вызовов:
* onLoad - один раз, мод загрузился, можно регистрировать события;
* onSecond - на игровом потоке, раз в игровую секунду;
* onError - если задача мода упала;
* onUnload - игра выключается.
*
* Всё, что здесь выполняется, уже выполняется на игровом потоке: трогать
* состояние игры из своего потока нельзя.
*/
public class ExampleMod implements RsxMod {
private static final String MOD = "example";
private Config config;
private int ticks;
@Override
public int apiVersion() {
// Версия API, под которую написан мод. Бриф предупредит в логе, если
// версии разойдутся, - это лучше, чем мод молча перестанет работать.
return Rsx.API_VERSION;
}
@Override
public void onLoad() {
// Настройки живут в config\example.json. Значения по умолчанию задаёт
// сам мод: если файла нет, он создастся с ними при первом save().
config = Config.of(MOD)
.set("statusEverySeconds", 5)
.set("greetOnJoin", true);
config.save();
int every = clamp(config.integer("statusEverySeconds", 5), 1, 3600);
Log.info(MOD, "загружен");
Log.info(MOD, "сборка игры: " + Rsx.buildId());
Log.info(MOD, "API: " + Rsx.apiVersion() + ", библиотека: " + Rsxm.describe());
Log.info(MOD, "настройки: " + config.file().getName()
+ ", отчёт раз в " + every + " с");
Log.info(MOD, "Minecraft это " + Rsx.describe("net.minecraft.client.Minecraft")
+ ", игрок это " + Rsx.describe("net.minecraft.client.entity.EntityPlayerSP"));
// События вместо опроса: срабатывают, когда что-то действительно
// изменилось, а не каждый тик.
if (config.bool("greetOnJoin", true)) {
Rsx.on("playerJoin", new Runnable() {
@Override
public void run() {
Log.info(MOD, "игрок вошёл: " + Rsx.player().getName());
}
});
Rsx.on("playerLeave", new Runnable() {
@Override
public void run() {
Log.info(MOD, "игрок вышел");
}
});
}
Rsx.on("worldChange", new Runnable() {
@Override
public void run() {
Log.info(MOD, "сменился мир, время: " + Rsx.world().getTime());
}
});
// Периодичность по тикам, а не по миллисекундам: тики не плывут при
// лагах и не срабатывают дважды после фриза.
final int period = every;
Rsx.everyTicks(20 * period, new Runnable() {
@Override
public void run() {
onSecond(period);
}
});
}
/** Раз в игровую секунду, на игровом потоке. */
private void onSecond(int period) {
ticks++;
if (ticks % period != 0) {
return;
}
Rsx.Player player = Rsx.player();
if (!player.isPresent()) {
Log.info(MOD, "тик " + Rsx.tick() + ": игрока нет (меню?)");
return;
}
// Никаких getName() по обфусцированному полю: обёртка сама знает,
// как до него добраться в этой сборке.
Log.info(MOD, String.format(
"%d/%d | %s (%.1f/%.1f/%.1f) hp %.1f/%.1f | мир t=%d%s | экран: %s",
ticks,
period,
player.getName(),
player.getX(), player.getY(), player.getZ(),
player.getHealth(), player.getMaxHealth(),
Rsx.world().getTime(),
Rsx.world().isClient() ? "" : " [выделенный сервер]",
Rsx.currentScreen() == null
? "нет"
: Rsx.currentScreen().getClass().getSimpleName()));
}
@Override
public void onError(Throwable error) {
// Мод упал. По умолчанию бриф только пишет в лог, но мод может решить,
// что делать: например, отключить проблемную задачу.
Log.error(MOD, "задача упала: " + error, error);
}
@Override
public void onUnload() {
// Игра выключается: освобождаем свои ресурсы, если они есть.
Log.info(MOD, "выгружаюсь");
if (config != null) {
config.save();
}
}
private static int clamp(int v, int min, int max) {
return v < min ? min : (v > max ? max : v);
}
}