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

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

Форк
- 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,199 @@
package net.rustnotmc.client;
import java.io.File;
import java.util.List;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.ScaledResolution;
import net.minecraft.client.renderer.GlStateManager;
import net.rustnotmc.api.client.ClientHost;
import net.rustnotmc.api.hud.HudLayout;
import net.rustnotmc.loader.Log;
import net.rustnotmc.loader.ModContainer;
import net.rustnotmc.loader.ModLoader;
/**
* Where the client and the mod loader meet.
*
* The loader is a library, not an agent. The game calls in at two points: once
* during startup, and once per frame. Everything a mod does hangs off those two
* calls, so the loader never has to know how a game loop is shaped.
*
* Every entry point is wrapped. A mod that throws must not be able to take the
* client down with it, and a loader that cannot start must not stop the game
* from being a game.
*
* This file lives in game-fork/src-overlay because src is regenerated from the
* vanilla jar on every build and anything written there is lost.
*/
public final class RsxBootstrap {
private static ModLoader loader;
private static RsxHudSurface surface;
private static RsxClientServices client;
private static boolean broken;
private static boolean frameFailed;
private RsxBootstrap() {
}
/** True once mods have been discovered and enabled. */
public static boolean ready() {
return loader != null && !broken;
}
public static ModLoader loader() {
return loader;
}
public static int loadedMods() {
return loader == null ? 0 : loader.count();
}
/**
* Discovers and enables mods. Called once, after the client has a display,
* a font and a resource manager, so a mod may touch any of them.
*/
public static synchronized void start(Minecraft minecraft) {
if (loader != null || broken) {
return;
}
try {
// the client API goes up before any mod runs, so onConstruct can use
// it: a mod registers commands and reads the screen there
client = new RsxClientServices(minecraft);
ClientHost.install(client);
File directory = new File(minecraft.gameDir, "mods");
loader = new ModLoader(directory);
int found = loader.discover();
Log.info("RustNotMC: scanning " + directory + ", " + found + " mod jar(s)");
if (!loader.resolve()) {
reportProblems();
broken = true;
loader = null;
return;
}
loader.loadAll();
surface = new RsxHudSurface(minecraft);
reportProblems();
if (loader.count() == 0) {
Log.info("RustNotMC: no mods loaded");
return;
}
StringBuilder line = new StringBuilder("RustNotMC: loaded ");
List<ModContainer> mods = loader.mods();
for (int i = 0; i < mods.size(); i++) {
if (i > 0) {
line.append(", ");
}
ModContainer mod = mods.get(i);
line.append(mod.id()).append(' ').append(mod.version());
}
Log.info(line.toString());
} catch (Throwable throwable) {
broken = true;
loader = null;
Log.error("RustNotMC: mod loader failed to start", throwable);
}
}
/**
* Offers a typed chat line to the mod commands.
*
* Called from the point where a line leaves the client. Returns true when a
* CLIENT scoped command carried it out and the line must not be sent on.
* SERVER and BOTH commands return false on purpose: the server is the one
* that has to see them and decide.
*/
public static boolean handleChatCommand(String line) {
if (client == null || !ClientHost.present()) {
return false;
}
return client.dispatchCommand(line, localName());
}
private static String localName() {
Minecraft mc = Minecraft.getMinecraft();
if (mc != null && mc.getSession() != null) {
return mc.getSession().getUsername();
}
return "local";
}
private static void reportProblems() {
List<String> problems = loader.problems();
for (int i = 0; i < problems.size(); i++) {
Log.error("RustNotMC: " + problems.get(i));
}
}
/**
* Runs scheduled mod work and draws registered overlays.
*
* Called once per frame with the main framebuffer bound and the game's own
* matrices already unwound, so the overlay sets up its own projection and
* leaves the state it did not set alone.
*/
public static void renderFrame(Minecraft minecraft) {
if (loader == null || surface == null || !surface.available()) {
return;
}
if (frameFailed) {
return;
}
if (client != null) {
client.advanceFrame();
}
try {
loader.tick();
if (client != null) {
client.advanceTick();
}
ScaledResolution scaled = new ScaledResolution(minecraft);
int width = scaled.getScaledWidth();
int height = scaled.getScaledHeight();
if (width <= 0 || height <= 0) {
return;
}
// the overlay draws in flat screen space, so depth would fight it
// and the blend state has to be ours for the duration
GlStateManager.disableDepth();
GlStateManager.pushMatrix();
GlStateManager.matrixMode(5889);
GlStateManager.loadIdentity();
GlStateManager.ortho(0.0D, width, height, 0.0D, 1000.0D, 3000.0D);
GlStateManager.matrixMode(5888);
GlStateManager.loadIdentity();
GlStateManager.translate(0.0F, 0.0F, -2000.0F);
GlStateManager.enableBlend();
GlStateManager.tryBlendFuncSeparate(
GlStateManager.SourceFactor.SRC_ALPHA,
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA,
GlStateManager.SourceFactor.ONE,
GlStateManager.DestFactor.ZERO);
try {
HudLayout.render(surface);
} finally {
GlStateManager.disableBlend();
GlStateManager.enableDepth();
GlStateManager.popMatrix();
}
} catch (Throwable throwable) {
// one bad frame is a bug worth seeing, but it must not become a
// per-frame stack trace or a dead client
frameFailed = true;
Log.error("RustNotMC: overlay rendering failed, disabling overlays", throwable);
}
}
}
@@ -0,0 +1,233 @@
package net.rustnotmc.client;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiChat;
import net.minecraft.client.gui.GuiIngameMenu;
import net.minecraft.client.gui.GuiMainMenu;
import net.minecraft.client.gui.GuiOptions;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.gui.GuiWorldSelection;
import net.minecraft.client.gui.achievement.GuiStats;
import net.minecraft.client.gui.advancements.GuiScreenAdvancements;
import net.minecraft.client.gui.inventory.GuiContainer;
import net.minecraft.client.settings.KeyBinding;
import net.rustnotmc.api.client.ClientServices;
import net.rustnotmc.api.client.Commands;
import net.rustnotmc.api.client.Input;
import net.rustnotmc.api.client.Screen;
import net.rustnotmc.api.client.ScreenKind;
import org.lwjgl.input.Mouse;
/**
* The client's implementation of the mod API.
*
* Everything here reads presentation state the client already has: what screen
* is open, what the window measures, which key is held. Nothing reaches into
* the world, a player or a packet, because the server owns those and a client
* mod is not allowed to decide them.
*
* The values are recomputed per call rather than cached. A mod may ask once a
* frame, and a stale answer would be worse than a cheap one.
*
* Lives in game-fork/src-overlay; src is regenerated on every build.
*/
public final class RsxClientServices implements ClientServices {
private final Minecraft minecraft;
private final RsxScreen screen = new RsxScreen();
private final RsxInput input = new RsxInput();
private final RsxCommands commands = new RsxCommands();
private long tick;
private long frame;
public RsxClientServices(Minecraft minecraft) {
this.minecraft = minecraft;
}
/** Called once per frame by the bootstrap, before anything is asked. */
public void advanceFrame() {
this.frame++;
}
/** Called once per tick by the bootstrap. */
public void advanceTick() {
this.tick++;
}
boolean dispatchCommand(String line, String sender) {
return this.commands.dispatch(line, sender);
}
@Override
public Screen screen() {
return this.screen;
}
@Override
public Input input() {
return this.input;
}
@Override
public Commands commands() {
return this.commands;
}
@Override
public long tick() {
return this.tick;
}
@Override
public long frame() {
return this.frame;
}
private final class RsxScreen implements Screen {
@Override
public ScreenKind kind() {
GuiScreen current = minecraft.currentScreen;
if (current == null) {
return minecraft.world == null ? ScreenKind.UNKNOWN : ScreenKind.IN_GAME;
}
if (current instanceof GuiChat) {
return ScreenKind.CHAT;
}
if (current instanceof GuiIngameMenu) {
return ScreenKind.PAUSED;
}
if (current instanceof GuiMainMenu) {
return ScreenKind.MAIN_MENU;
}
if (current instanceof GuiWorldSelection) {
return ScreenKind.WORLD_SELECTION;
}
if (current instanceof GuiOptions) {
return ScreenKind.OPTIONS;
}
if (current instanceof GuiContainer) {
return ScreenKind.INVENTORY;
}
if (current instanceof GuiScreenAdvancements || current instanceof GuiStats) {
return ScreenKind.ADVANCEMENTS;
}
// a screen this build has no name for, over a loaded world or not
return minecraft.world == null ? ScreenKind.UNKNOWN : ScreenKind.PAUSED;
}
@Override
public int width() {
return minecraft.displayWidth / Math.max(1, scaleFactor());
}
@Override
public int height() {
return minecraft.displayHeight / Math.max(1, scaleFactor());
}
@Override
public float scale() {
return (float) scaleFactor();
}
@Override
public int windowWidth() {
return minecraft.displayWidth;
}
@Override
public int windowHeight() {
return minecraft.displayHeight;
}
private int scaleFactor() {
int gui = minecraft.gameSettings == null ? 2 : minecraft.gameSettings.guiScale;
if (gui <= 0) {
// auto: the client picks the largest scale that keeps the
// interface at least this wide
int width = minecraft.displayWidth;
gui = 1;
while (width / (gui + 1) >= 320 && gui < 4) {
gui++;
}
}
return gui;
}
}
private final class RsxInput implements Input {
@Override
public boolean keyDown(String keyId) {
if (keyId == null || minecraft.gameSettings == null) {
return false;
}
KeyBinding[] bindings = minecraft.gameSettings.keyBindings;
if (bindings == null) {
return false;
}
for (int i = 0; i < bindings.length; i++) {
if (keyId.equals(bindings[i].getKeyDescription()) && bindings[i].isKeyDown()) {
return true;
}
}
return false;
}
@Override
public boolean anyKeyDown() {
KeyBinding[] bindings = minecraft.gameSettings == null
? null : minecraft.gameSettings.keyBindings;
if (bindings == null) {
return false;
}
for (int i = 0; i < bindings.length; i++) {
if (bindings[i].isKeyDown()) {
return true;
}
}
return false;
}
@Override
public boolean focused() {
return minecraft.inGameHasFocus;
}
@Override
public int mouseX() {
return scaled(Mouse.getX());
}
@Override
public int mouseY() {
return scaled(Mouse.getY());
}
@Override
public int mouseButtons() {
int buttons = 0;
if (Mouse.isButtonDown(0)) {
buttons |= 1;
}
if (Mouse.isButtonDown(1)) {
buttons |= 2;
}
if (Mouse.isButtonDown(2)) {
buttons |= 4;
}
return buttons;
}
@Override
public boolean mouseDown(int button) {
return button >= 0 && button <= 2 && Mouse.isButtonDown(button);
}
private int scaled(int windowCoord) {
return windowCoord / Math.max(1, screen.scaleFactor());
}
}
}
@@ -0,0 +1,132 @@
package net.rustnotmc.client;
import java.util.LinkedHashMap;
import java.util.Map;
import net.rustnotmc.api.client.ClientCommand;
import net.rustnotmc.api.client.CommandArgs;
import net.rustnotmc.api.client.CommandScope;
import net.rustnotmc.api.client.Commands;
import net.rustnotmc.loader.Log;
import net.rustnotmc.loader.ModLoader;
/**
* The mod command table.
*
* Only a CLIENT scoped command is carried out here. SERVER and BOTH are not
* consumed by the client at all: the line is left for the server to take, which
* is the whole point of the scope. The client never decides an effect the server
* owns.
*
* The table is shared by every mod, so a name can only be claimed once. A second
* mod asking for a taken name is refused loudly rather than silently shadowing
* the first, which would be miserable to debug.
*
* Lives in game-fork/src-overlay; src is regenerated on every build.
*/
final class RsxCommands implements Commands {
private static final class Entry {
final String owner;
final ClientCommand handler;
final CommandScope scope;
Entry(String owner, ClientCommand handler, CommandScope scope) {
this.owner = owner;
this.handler = handler;
this.scope = scope;
}
}
private final Map<String, Entry> entries = new LinkedHashMap<String, Entry>();
@Override
public boolean register(String name, ClientCommand handler, CommandScope scope) {
if (name == null || handler == null || scope == null) {
return false;
}
String key = normalise(name);
if (key.length() == 0) {
return false;
}
String owner = registering();
Entry clash = this.entries.get(key);
if (clash != null) {
if (clash.owner.equals(owner) && clash.handler == handler) {
return true;
}
Log.warn("command /" + key + " is already registered by " + clash.owner
+ "; the second registration was refused");
return false;
}
this.entries.put(key, new Entry(owner, handler, scope));
Log.info("registered command /" + key + " scope=" + scope.name().toLowerCase());
return true;
}
@Override
public boolean register(String name, ClientCommand handler) {
return register(name, handler, CommandScope.CLIENT);
}
@Override
public boolean isRegistered(String name) {
return name != null && this.entries.containsKey(normalise(name));
}
@Override
public CommandScope scopeOf(String name) {
Entry e = name == null ? null : this.entries.get(normalise(name));
return e == null ? null : e.scope;
}
@Override
public int count() {
return this.entries.size();
}
/**
* Offers a typed line to the mod commands.
*
* @return true when the client carried it out and the line must not be sent
* on. False means the line is not a client command, which is the
* answer for SERVER and BOTH: the server has to see those.
*/
boolean dispatch(String line, String sender) {
if (line == null || !line.startsWith("/")) {
return false;
}
String body = line.substring(1).trim();
int space = body.indexOf(' ');
String name = space < 0 ? body : body.substring(0, space);
Entry entry = this.entries.get(normalise(name));
if (entry == null || entry.scope != CommandScope.CLIENT) {
return false;
}
try {
entry.handler.run(CommandArgs.parse(body, sender));
} catch (Throwable t) {
Log.error("command /" + name + " failed", t);
}
return true;
}
private static String normalise(String name) {
String v = name.trim();
if (v.startsWith("/")) {
v = v.substring(1);
}
return v.toLowerCase();
}
/**
* Who is registering right now.
*
* The loader knows this because it is the one calling onConstruct, and the
* game can read it without the loader ever depending on the game.
*/
private static String registering() {
String who = ModLoader.REGISTERING.get();
return who == null ? "unknown" : who;
}
}
@@ -0,0 +1,72 @@
package net.rustnotmc.client;
import net.rustnotmc.loader.Log;
/**
* Temporary diagnostics, added to work out why jumping and flight misbehave.
*
* Static analysis kept producing confident and wrong answers about this path, so
* the state is measured instead. Delete this file and its patch entry once the
* movement bugs are closed.
*/
public final class RsxDebug {
private static int ticks;
private RsxDebug() {
}
/** One-off trace for the empty sound registry. */
public static void sound(String message) {
Log.info("DBG snd " + message);
}
/** Called from EntityPlayerSP.onLivingUpdate, once a second. */
public static void movement(Object self) {
if (++ticks % 20 != 0) {
return;
}
Log.info("DBG sp onGround=" + read(self, "onGround")
+ " inJump=" + read(self, "movementInput")
+ " isJumping=" + read(self, "isJumping")
+ " riding=" + read(self, "isRiding")
+ " srvWorld=" + read(self, "isServerWorld")
+ " allowFly=" + read(self, "allowFlying")
+ " flying=" + read(self, "isFlying")
+ " mY=" + read(self, "motionY")
+ " y=" + read(self, "posY"));
}
/**
* Reads a field by name.
*
* Deliberately reflective and deliberately forgiving: a diagnostic that
* throws when a field is renamed is worse than one that prints null.
*/
private static String read(Object target, String field) {
if (target == null) {
return "?";
}
try {
java.lang.reflect.Field f = findField(target.getClass(), field);
if (f == null) {
return "?";
}
f.setAccessible(true);
return String.valueOf(f.get(target));
} catch (Throwable t) {
return "!";
}
}
private static java.lang.reflect.Field findField(Class<?> type, String name) {
while (type != null) {
try {
return type.getDeclaredField(name);
} catch (NoSuchFieldException e) {
type = type.getSuperclass();
}
}
return null;
}
}
@@ -0,0 +1,137 @@
package net.rustnotmc.client;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.ScaledResolution;
import net.minecraft.client.renderer.BufferBuilder;
import net.minecraft.client.renderer.GlStateManager;
import net.minecraft.client.renderer.Tessellator;
import net.minecraft.client.renderer.vertex.DefaultVertexFormats;
import net.rustnotmc.api.hud.HudSurface;
/**
* The game's implementation of the mod HUD surface.
*
* A mod draws through this interface and never touches a game type, which is
* what lets the layout and the overlay code be tested with the game closed. The
* reverse cost is paid once, here: everything a mod needs from Minecraft is
* resolved in this class.
*
* Coordinates are the scaled interface ones, so an element lines up with the
* health bar and the chat without the mod knowing about GUI scale.
*
* Lives in game-fork/src-overlay; src is regenerated on every build.
*/
public final class RsxHudSurface implements HudSurface {
private final Minecraft minecraft;
/**
* Rebuilt only when the window or the scale factor actually changes. A new
* ScaledResolution per frame would be one short-lived object sixty times a
* second for a value that is almost never different.
*/
private ScaledResolution resolution;
private int cachedWidth = -1;
private int cachedHeight = -1;
private int cachedScale = -1;
public RsxHudSurface(Minecraft minecraft) {
this.minecraft = minecraft;
}
private ScaledResolution resolution() {
int width = this.minecraft.displayWidth;
int height = this.minecraft.displayHeight;
int scale = this.minecraft.gameSettings.guiScale;
if (this.resolution == null
|| width != this.cachedWidth
|| height != this.cachedHeight
|| scale != this.cachedScale) {
this.resolution = new ScaledResolution(this.minecraft);
this.cachedWidth = width;
this.cachedHeight = height;
this.cachedScale = scale;
}
return this.resolution;
}
@Override
public boolean available() {
return this.minecraft != null && this.minecraft.fontRenderer != null;
}
@Override
public int width() {
return this.available() ? resolution().getScaledWidth() : 0;
}
@Override
public int height() {
return this.available() ? resolution().getScaledHeight() : 0;
}
@Override
public float scale() {
return (float) resolution().getScaleFactor();
}
@Override
public void rect(float x, float y, float w, float h, int rgb, float alpha) {
if (w <= 0.0F || h <= 0.0F) {
return;
}
int a = clampAlpha(alpha);
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
// the vanilla debug overlay draws coloured quads the same way; this
// GlStateManager has no isTextureEnabled to ask, so the state is set
// explicitly the way the game itself does it
GlStateManager.disableTexture2D();
Tessellator tessellator = Tessellator.getInstance();
BufferBuilder buffer = tessellator.getBuffer();
buffer.begin(7, DefaultVertexFormats.POSITION_COLOR);
buffer.pos(x, y + h, 0.0D).color(r, g, b, a).endVertex();
buffer.pos(x + w, y + h, 0.0D).color(r, g, b, a).endVertex();
buffer.pos(x + w, y, 0.0D).color(r, g, b, a).endVertex();
buffer.pos(x, y, 0.0D).color(r, g, b, a).endVertex();
tessellator.draw();
GlStateManager.enableTexture2D();
}
@Override
public void text(float x, float y, String value, int rgb, float alpha) {
if (value == null || value.isEmpty() || !this.available()) {
return;
}
GlStateManager.enableTexture2D();
this.minecraft.fontRenderer.drawStringWithShadow(
value, x, y, (rgb & 0x00FFFFFF) | (clampAlpha(alpha) << 24));
}
@Override
public float textWidth(String value) {
if (value == null || value.isEmpty() || !this.available()) {
return 0.0F;
}
return (float) this.minecraft.fontRenderer.getStringWidth(value);
}
@Override
public int textHeight() {
return this.available() ? this.minecraft.fontRenderer.FONT_HEIGHT : 0;
}
private static int clampAlpha(float alpha) {
if (alpha < 0.0F) {
return 0;
}
if (alpha > 1.0F) {
return 255;
}
return (int) (alpha * 255.0F + 0.5F);
}
}