commit bdf0ae866afcad5864d61def22aca97633c8e134 Author: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Sun Oct 18 21:31:27 2026 +0300 Форк клиента собирается из исходников, модзагрузчик встроен в игру Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - 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 для запуска клиента diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55809f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# The fork is rebuilt from the verified vanilla jar, and everything under these +# paths is either Mojang-derived or generated from it. None of it is ours to +# publish: game-fork/src is decompiled Mojang code, game-fork/classes and +# game-fork/mappings are the remapped jar. What we wrote is in src-overlay, +# patches and scripts, and that is what belongs in this repository. +game-fork/src/ +game-fork/classes/ +game-fork/build/ +game-fork/mappings/ +game-fork/struct-diff.json + +# A game directory: saves, logs, screenshots, native libraries, installed mods. +run/ + +# build output +build/ +dist/ +out/ +target/ +*.class + +# jars are build products; the third-party ones we redistribute are un-ignored +*.jar +!rsx-client/third_party/**/*.jar + +# python +__cache__/ +__pycache__/ +*.pyc + +# editors +.idea/ +.vscode/ +*.iml + +# os +Thumbs.db +desktop.ini +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8dbcc5 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# RustNotMC + +A survival game built on a Minecraft 1.12.2 client as the rendering platform, +with its own server, its own protocol and its own accounts. + +## Repositories + +| Repository | License | What lives there | +|---|---|---| +| `rsx-client` | MIT | Loader, mod API, client hooks, deobfuscation tools | +| `rsx-protocol` | MIT | Wire format, packet schemas, shared simulation library | +| `rsx-plugin-api` | MIT | Interfaces for server-side content | +| `rsx-server-core` | proprietary | Tick loop, world, entities, scaling | +| `rsx-auth` | proprietary | Accounts, tokens, revocation | + +## Target + +Base platform is Minecraft 1.12.2, forked into our own source tree so the game +is moddable from the start rather than patched from outside. + +- game fork: Java 17, compiled by `game-fork\scripts\prepare-game.ps1` +- loader and mod API: Java 17 +- mods: Java only + +Capacity target is 200 players on 4 cores and 8 GB of RAM, scaling to 500 on +larger hardware. + +## The fork + +The client is not wrapped around the game jar. `game-fork` rebuilds it from the +verified vanilla jar into readable source, and the loader is an ordinary part +of that game rather than an agent injected into someone else's process. + +``` +vanilla client-1.12.2.jar verified Mojang signature + -> mcp-1.12.2-srg.zip 3344 classes, 20817 members + -> merge with MCP CSVs 9991 fields, 18715 methods renamed + -> SpecialSource 3310 classes in MCP names + -> FernFlower 2050 source files + -> hand patches + rules 103 patches, one regex rule + -> javac 3261 classes, clean +``` + +`prepare-game.ps1` runs the whole thing and is idempotent. Hand fixes live in +`game-fork\patches\decompiler-fixes.txt` and are re-applied on every run, so +regenerating the tree never loses them. + +The fork holds Mojang-derived code and is **not published**. What ships in the +open repository is the loader, the mod API and the build tools. + +## Modding + +Mods are jars with a descriptor at `META-INF/rsxmod.properties`: + +```properties +id=rsxm-lib +name=RustNotMC Library +version=1.0.0 +env=CLIENT +entrypoint=net.rustnotmc.lib.LibMod +access=hud, text, world, player +depends.core=>=1.0 +``` + +`env` is `CLIENT`, `SERVER` or `BOTH`. The loader runs on the client today; +the field exists so a mod that targets both sides does not need a format +change when the loader moves to the server. + +Each mod gets its own class loader. Mods declare the capabilities they need and +the loader grants them; `reflection` is never granted without explicit operator +consent. + +Load order comes from the dependency graph, not from filenames. Shutdown runs +in reverse. + +## Building + +```powershell +rsx-client\scripts\build.ps1 # dist\rsx-loader.jar +rsx-client\scripts\test.ps1 # self test, 96 checks +game-fork\scripts\prepare-game.ps1 # rebuild the fork from the vanilla jar +game-fork\scripts\compile-game.ps1 # compile only, fast iteration +``` + +Only a JDK is required. No Gradle, no network once the mappings are cached. + +## Tools + +| Script | Purpose | +|---|---| +| `rsx-client\tools\merge_srg_mcp.py` | vanilla SRG plus MCP CSVs into one obf to MCP SRG | +| `rsx-client\tools\apply_patches.py` | re-applies the hand fixes, fails loudly if one misses | +| `rsx-client\tools\fix_stale_locals.py` | FernFlower's stale local references, scoped per method | +| `rsx-client\tools\fix_generic_casts.py` | inserts the cast where a generic loses its type parameter | +| `rsx-client\tools\fix_decompiler_rules.py` | systematic rewrites that would be hundreds of patches | +| `rsx-client\tools\show_errors.py` | javac errors next to the source line that caused them | + +## Legal + +Minecraft is a trademark of Mojang Studios. This project is not affiliated with +or endorsed by Mojang or Microsoft. Game assets are not redistributed here. \ No newline at end of file diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt new file mode 100644 index 0000000..6463d64 --- /dev/null +++ b/game-fork/patches/decompiler-fixes.txt @@ -0,0 +1,1122 @@ +# Hand fixes applied on top of FernFlower's output. +# +# Decompiling is repeatable; these corrections are not, so they live here and +# get re-applied by tools/apply_patches.py after every re-decompile. +# +# Why each one is needed is in the patch body. + +=== FILE net/minecraft/block/BlockPortal.java +--- BEFORE +PropertyEnum.create("axis", EnumFacing.Axis.class, (Enum[])(EnumFacing.Axis.X, EnumFacing.Axis.Z)); +--- AFTER +PropertyEnum.create("axis", EnumFacing.Axis.class, new EnumFacing.Axis[]{EnumFacing.Axis.X, EnumFacing.Axis.Z}); + +=== FILE net/minecraft/util/IObjectIntIterable.java +--- REPLACE +package net.minecraft.util; + +/** + * Signature restored by hand. + * + * ProGuard dropped the generic Signature attribute from this class, so the + * decompiler emitted it raw while every use site still carried a type argument. + * That single missing parameter produced the majority of the compile errors: + * raw here means raw everywhere downstream. + * + * There is no method on this interface. The "object to int" name comes from the + * classes that implement it, which each expose their own lookup; declaring one + * here would force every implementation to invent it. + */ +public interface IObjectIntIterable extends Iterable { +} +# An Object[] cast assigned into a K[] field. Vanilla writes (K[])new Object[n], +# an unchecked cast that compiles; the decompiler emitted the erased type +# instead, which does not. +=== FILE net/minecraft/util/IntIdentityHashBiMap.java +--- BEFORE +(Object[]) (new Object[i]) +--- AFTER +(K[]) (new Object[i]) + +# getEntities/getPlayers take Predicate, but the decompiler typed the +# loop variable as raw Entity, so the call cannot be checked. Declaring it as T +# is what vanilla does. Multi-line context on purpose: a bare "list.add(entity)" +# would also rewrite this.loadedEntityList.add(entity). +=== FILE net/minecraft/world/storage/loot/properties/EntityPropertyManager.java +--- BEFORE + throw new IllegalArgumentException("Unknown loot entity property " + t0); + } else { + return entityproperty_serializer; +--- AFTER + throw new IllegalArgumentException("Unknown loot entity property " + t0); + } else { + return (EntityProperty.Serializer) entityproperty_serializer; + +=== FILE net/minecraft/world/World.java +--- BEFORE + if (oclass.isAssignableFrom(entity.getClass()) && predicate.apply(entity)) { + list.add(entity); + } +--- AFTER + if (oclass.isAssignableFrom(entity.getClass()) && predicate.apply((T) entity)) { + list.add((T) entity); + } + +=== FILE net/minecraft/world/World.java +--- BEFORE + T t2 = (Entity) list.get(i); +--- AFTER + T t2 = (T) list.get(i); + +# Overriding methods came out protected while every one of them is public in +# MinecraftServer. Narrowing visibility is not an override, so all six fail. +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected ServerCommandManager createCommandManager() { +--- AFTER + public ServerCommandManager createCommandManager() { + +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected void loadAllWorlds(String s, String s1, long i, WorldType worldtype, String s2) { +--- AFTER + public void loadAllWorlds(String s, String s1, long i, WorldType worldtype, String s2) { + +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected boolean init() throws IOException { +--- AFTER + public boolean init() throws IOException { + +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected void tick() { +--- AFTER + public void tick() { + +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected void saveAllWorlds(boolean flag) { +--- AFTER + public void saveAllWorlds(boolean flag) { + +=== FILE net/minecraft/server/integrated/IntegratedServer.java +--- BEFORE + protected void finalTick(CrashReport crashreport) { +--- AFTER + public void finalTick(CrashReport crashreport) { + +# The constructor took a raw Function, so no constructor reference could bind to +# it. Typing it as the field is what lets MovementStep::new resolve. +=== FILE net/minecraft/client/tutorial/TutorialSteps.java +--- BEFORE + private TutorialSteps(String s, Function function) { +--- AFTER + private TutorialSteps(String s, Function function) { + +# Erased types where the surrounding signature is generic: Object[] cannot be +# assigned to T[], and a raw Iterator yields Object. +=== FILE net/minecraft/util/math/Cartesian.java +--- BEFORE + Iterator iterator = iterable.iterator(); + + while (iterator.hasNext()) { + T t0 = iterator.next(); +--- AFTER + Iterator iterator = iterable.iterator(); + + while (iterator.hasNext()) { + T t0 = iterator.next(); + +=== FILE net/minecraft/util/math/Cartesian.java +--- BEFORE + return (Object[]) list.toArray(createArray(oclass, list.size())); +--- AFTER + return (T[]) list.toArray(createArray(oclass, list.size())); + +=== FILE net/minecraft/util/math/Cartesian.java +--- BEFORE + return (Object[]) ((Object[]) Array.newInstance(oclass, i)); +--- AFTER + return (T[]) Array.newInstance(oclass, i); + +=== FILE net/minecraft/util/math/Cartesian.java +--- BEFORE + return (Object[]) this.results.clone(); +--- AFTER + return (T[]) this.results.clone(); + +# matchOneEntity returns T, not Entity. And the team/name/tag arguments are +# rewritten below their own declaration, so they cannot be final. +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + return list.size() == 1 ? (Entity) list.get(0) : null; +--- AFTER + return list.size() == 1 ? (T) list.get(0) : null; + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + return Lists.newArrayList(new Entity[]{entity}); +--- AFTER + return Lists.newArrayList((T) entity); + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + final String s = getArgument(map, EntitySelector.ARGUMENT_TEAM_NAME); +--- AFTER + String s = getArgument(map, EntitySelector.ARGUMENT_TEAM_NAME); + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + final String s = getArgument(map, EntitySelector.ARGUMENT_PLAYER_NAME); +--- AFTER + String s = getArgument(map, EntitySelector.ARGUMENT_PLAYER_NAME); + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + final String s = getArgument(map, EntitySelector.ARGUMENT_ENTITY_TAG); +--- AFTER + String s = getArgument(map, EntitySelector.ARGUMENT_ENTITY_TAG); + +# FernFlower wrote "null." where the owning type belonged, so every setRespawnState +# call referenced a literal null. The constants live on DragonSpawnManager itself. +=== FILE net/minecraft/world/end/DragonSpawnManager.java +--- BEFORE +setRespawnState(null. +--- AFTER +setRespawnState(DragonSpawnManager. + +# getEntityClassRenderObject is generic in T but the local was declared with a +# wildcard, so the recursive call and the return could not be checked. Also the +# ternary has no inferable T at all. +=== FILE net/minecraft/client/renderer/entity/RenderManager.java +--- BEFORE + Render render = (Render) this.entityRenderMap.get(oclass); +--- AFTER + Render render = (Render) this.entityRenderMap.get(oclass); + +=== FILE net/minecraft/client/renderer/entity/RenderManager.java +--- BEFORE + render = this.getEntityClassRenderObject(oclass.getSuperclass()); +--- AFTER + render = (Render) this.getEntityClassRenderObject(oclass.getSuperclass()); + +=== FILE net/minecraft/client/renderer/entity/RenderManager.java +--- BEFORE + return renderplayer != null ? renderplayer : this.playerRenderer; +--- AFTER + return (Render) (renderplayer != null ? renderplayer : this.playerRenderer); + +# UserListEntry is the erased supertype; the method is generic in V, so the cast +# has to be to V. createEntry also needs its type argument spelled out because the +# null argument gives the compiler nothing to infer from. +=== FILE net/minecraft/server/management/UserList.java +--- BEFORE + return (UserListEntry) this.values.get(this.getObjectKey(k0)); +--- AFTER + return (V) this.values.get(this.getObjectKey(k0)); + +=== FILE net/minecraft/server/management/UserList.java +--- BEFORE + V v0 = (UserListEntry) iterator.next(); +--- AFTER + V v0 = (V) iterator.next(); + +=== FILE net/minecraft/server/management/UserList.java +--- BEFORE + K k0 = iterator.next(); +--- AFTER + K k0 = (K) iterator.next(); + +=== FILE net/minecraft/server/management/UserList.java +--- BEFORE + return new UserListEntry<>((Object) null, jsonobject); +--- AFTER + return new UserListEntry((Object) null, jsonobject); + +# Erased types where the surrounding signature is generic. In every case below +# the decompiler substituted the supertype it could see for the type parameter. +=== FILE net/minecraft/util/ClassInheritanceMultiMap.java +--- BEFORE + T t0 = iterator.next(); +--- AFTER + T t0 = (T) iterator.next(); + +=== FILE net/minecraft/util/ClassInheritanceMultiMap.java +--- BEFORE + this.map.put(oclass, Lists.newArrayList(new Object[]{t0})); +--- AFTER + this.map.put(oclass, Lists.newArrayList(t0)); + +=== FILE net/minecraft/util/ClassInheritanceMultiMap.java +--- BEFORE + T t0 = object; +--- AFTER + T t0 = (T) object; + +# getName is generic in the property's own type, so the redundant (Enum) cast +# forced the argument to Enum and broke the call. +=== FILE net/minecraft/client/renderer/BlockModelShapes.java +--- BEFORE +BlockDirt.VARIANT.getName((Enum) ((BlockDirt.DirtType) map.remove(BlockDirt.VARIANT))) +--- AFTER +BlockDirt.VARIANT.getName((BlockDirt.DirtType) map.remove(BlockDirt.VARIANT)) + +=== FILE net/minecraft/client/renderer/BlockModelShapes.java +--- BEFORE +BlockStoneSlab.VARIANT.getName((Enum) ((BlockStoneSlab.EnumType) map.remove(BlockStoneSlab.VARIANT))) +--- AFTER +BlockStoneSlab.VARIANT.getName((BlockStoneSlab.EnumType) map.remove(BlockStoneSlab.VARIANT)) + +=== FILE net/minecraft/client/renderer/BlockModelShapes.java +--- BEFORE +BlockStoneSlabNew.VARIANT.getName((Enum) ((BlockStoneSlabNew.EnumType) map.remove(BlockStoneSlabNew.VARIANT))) +--- AFTER +BlockStoneSlabNew.VARIANT.getName((BlockStoneSlabNew.EnumType) map.remove(BlockStoneSlabNew.VARIANT)) + +# readArray returns List but stores byte, long and int, none of +# which is T. Vanilla keeps this list raw for exactly that reason. +=== FILE net/minecraft/nbt/JsonToNBT.java +--- BEFORE + List list = Lists.newArrayList(); +--- AFTER + List list = Lists.newArrayList(); + +=== FILE net/minecraft/block/state/BlockStateContainer.java +--- BEFORE + T t0 = (Comparable) iterator.next(); +--- AFTER + T t0 = (T) iterator.next(); + +=== FILE net/minecraft/block/state/BlockStateContainer.java +--- BEFORE + return (Comparable) iproperty.getValueClass().cast(comparable); +--- AFTER + return (T) iproperty.getValueClass().cast(comparable); + +# RoomCollection and SecondFloor both have no-argument constructors; the +# decompiler passed a literal null that could not match any of them. +=== FILE net/minecraft/world/gen/structure/WoodlandMansionPieces.java +--- BEFORE + super(null); +--- AFTER + super(); + +=== FILE net/minecraft/command/CommandBase.java +--- BEFORE + return (Entity) object; +--- AFTER + return (T) object; + +=== FILE net/minecraft/command/CommandBase.java +--- BEFORE + return iblockstate.withProperty(iproperty, comparable); +--- AFTER + return iblockstate.withProperty(iproperty, (T) comparable); + +=== FILE net/minecraft/command/CommandBase.java +--- BEFORE + return (Comparable) iproperty.parseValue(s).orNull(); +--- AFTER + return (T) iproperty.parseValue(s).orNull(); + +# withProperty and getName both need the property's own type parameter, not the +# Comparable supertype the decompiler settled on. +=== FILE net/minecraft/nbt/NBTUtil.java +--- BEFORE + return iblockstate.withProperty(iproperty, (Comparable) optional.get()); +--- AFTER + return iblockstate.withProperty(iproperty, (T) optional.get()); + +=== FILE net/minecraft/nbt/NBTUtil.java +--- BEFORE + return iproperty.getName(comparable); +--- AFTER + return iproperty.getName((T) comparable); + +# raw Entry, so getKey() is Object and cannot go straight into a Map +=== FILE net/minecraft/profiler/Snooper.java +--- BEFORE + map.put(entry.getKey(), entry.getValue().toString()); +--- AFTER + map.put((String) entry.getKey(), entry.getValue().toString()); + +# getPropertyName returns String, so the cast belongs on the argument, not on +# the call. Casting the result produced a second error instead of fixing one. +=== FILE net/minecraft/block/state/BlockStateBase.java +--- BEFORE + return iproperty.getName(comparable); +--- AFTER + return iproperty.getName((T) comparable); + +=== FILE net/minecraft/client/renderer/block/statemap/StateMapperBase.java +--- BEFORE + return iproperty.getName(comparable); +--- AFTER + return iproperty.getName((T) comparable); + +# Advancement.Builder.resolveParent takes a java.util.function.Function while +# Guava's forMap hands back com.google.common.base.Function. Bridging with a +# method reference keeps both sides on their own interface. +=== FILE net/minecraft/advancements/AdvancementList.java +--- BEFORE + Function function = Functions.forMap(this.advancements, (Object) null); +--- AFTER + Function function = Functions.forMap(this.advancements, null)::apply; + +# criteria is keyed by String, the set is a keySet: the second pass needs its +# own iterator of the right element type. +=== FILE net/minecraft/advancements/AdvancementProgress.java +--- BEFORE + iterator = set.iterator(); + + while (iterator.hasNext()) { + String s = (String) iterator.next(); +--- AFTER + Iterator iterator1 = set.iterator(); + + while (iterator1.hasNext()) { + String s = iterator1.next(); + +=== FILE net/minecraft/block/state/BlockStateBase.java +--- BEFORE + return this.withProperty(iproperty, (Comparable) cyclePropertyValue(iproperty.getAllowedValues(), this.getValue(iproperty))); +--- AFTER + return this.withProperty(iproperty, (T) cyclePropertyValue(iproperty.getAllowedValues(), this.getValue(iproperty))); + +# getAllowedValues hands back Collection, which is not the declared +# element type. Vanilla keeps this list raw for the same reason. +=== FILE net/minecraft/block/state/BlockStateContainer.java +--- BEFORE + List>> list = Lists.newArrayList(); +--- AFTER + List list = Lists.newArrayList(); + +=== FILE net/minecraft/block/state/pattern/BlockStateMatcher.java +--- BEFORE + return predicate.apply(iblockstate.getValue(iproperty)); +--- AFTER + return ((Predicate) predicate).apply(iblockstate.getValue(iproperty)); + +=== FILE net/minecraft/block/state/pattern/FactoryBlockPattern.java +--- BEFORE + this.symbolMap.put(c0, (Object) null); +--- AFTER + this.symbolMap.put(c0, (Predicate) null); + +# the local is filled from an ArrayList and from a List, so List is the only +# type both fit +=== FILE net/minecraft/client/gui/GuiIngame.java +--- BEFORE + ArrayList arraylist; +--- AFTER + List arraylist; + +=== FILE net/minecraft/client/gui/toasts/GuiToast.java +--- BEFORE + return guitoast_toastinstance.getToast(); +--- AFTER + return (T) guitoast_toastinstance.getToast(); + +# the counter is reused below the loop, so it cannot be final +=== FILE net/minecraft/client/particle/ParticleManager.java +--- BEFORE + for (final int i = 0; i < 3; ++i) { +--- AFTER + for (int i = 0; i < 3; ++i) { + +=== FILE net/minecraft/client/renderer/ItemRenderer.java +--- BEFORE + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp); +--- AFTER + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp, RenderPlayer.class); + +# getHardware() is declared without getProcessors in the public interface; the +# com.sun one is needed, and its Processor type is not named so the class does +# not have to be resolved at compile time. +=== FILE net/minecraft/client/renderer/OpenGlHelper.java +--- BEFORE + Processor[] aprocessor = (new SystemInfo()).getHardware().getProcessors(); +--- AFTER + Object[] aprocessor = (Object[]) ((com.sun.management.SystemInfo) new SystemInfo()).getHardware().getProcessors(); + + + +=== FILE net/minecraft/client/renderer/tileentity/TileEntityRendererDispatcher.java +--- BEFORE + TileEntitySpecialRenderer tileentityspecialrenderer = (TileEntitySpecialRenderer) this.renderers.get(oclass); +--- AFTER + TileEntitySpecialRenderer tileentityspecialrenderer = (TileEntitySpecialRenderer) this.renderers.get(oclass); + +=== FILE net/minecraft/client/renderer/tileentity/TileEntityRendererDispatcher.java +--- BEFORE + tileentityspecialrenderer = this.getRenderer(oclass.getSuperclass()); +--- AFTER + tileentityspecialrenderer = (TileEntitySpecialRenderer) this.getRenderer(oclass.getSuperclass()); + +# listFiles is overloaded on FileFilter and FilenameFilter, and the filter is +# an IOFileFilter implementing both +=== FILE net/minecraft/client/resources/FolderResourcePack.java +--- BEFORE + File[] afile = file.listFiles(DirectoryFileFilter.DIRECTORY); +--- AFTER + File[] afile = file.listFiles((java.io.FileFilter) DirectoryFileFilter.DIRECTORY); + +=== FILE net/minecraft/client/util/SearchTree.java +--- BEFORE + this.byName.add(t0, s.toLowerCase(Locale.ROOT)); +--- AFTER + this.byName.add(t0, ((String) s).toLowerCase(Locale.ROOT)); + +=== FILE net/minecraft/command/CommandSpreadPlayers.java +--- BEFORE + set.add((Object) null); +--- AFTER + set.add((Team) null); + +=== FILE net/minecraft/util/NonNullList.java +--- BEFORE + return new NonNullList<>(Arrays.asList(aobject), e0); +--- AFTER + return new NonNullList(Arrays.asList(aobject), e0); + +=== FILE net/minecraft/util/datafix/walkers/Filtered.java +--- BEFORE + this.key = EntityList.getKey(oclass); +--- AFTER + this.key = EntityList.getKey((Class) (Class) oclass); + +=== FILE net/minecraft/util/datafix/walkers/Filtered.java +--- BEFORE + this.key = TileEntity.getKey(oclass); +--- AFTER + this.key = TileEntity.getKey((Class) (Class) oclass); + +# getEntityRenderObject infers its type parameter from the assignment target and +# lands on Entity. Naming it gives the right renderer type. +=== FILE net/minecraft/client/renderer/ItemRenderer.java +--- BEFORE + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp, RenderPlayer.class); +--- AFTER + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp); + +# oshi exposes the processor list through CentralProcessor, not directly on +# HardwareAbstractionLayer. Naming Processor is unnecessary, so the class does +# not need to be resolved. +=== FILE net/minecraft/client/renderer/OpenGlHelper.java +--- BEFORE + Object[] aprocessor = (Object[]) ((com.sun.management.SystemInfo) new SystemInfo()).getHardware().getProcessors(); +--- AFTER + Object[] aprocessor = (Object[]) systeminfo.getHardware().getProcessor().getProcessors(); + +=== FILE net/minecraft/client/renderer/debug/DebugRendererNeighborsUpdate.java +--- BEFORE + iterator = map.entrySet().iterator(); +--- AFTER + iterator = (Iterator) map.entrySet().iterator(); + +=== FILE net/minecraft/client/renderer/entity/RenderLivingBase.java +--- BEFORE + return this.layerRenderers.add(u0); +--- AFTER + return this.layerRenderers.add((LayerRenderer) u0); + +=== FILE net/minecraft/client/renderer/entity/RenderManager.java +--- BEFORE + render = (Render) this.getEntityClassRenderObject(oclass.getSuperclass()); +--- AFTER + render = (Render) this.getEntityClassRenderObject((Class) (Class) oclass.getSuperclass()); + +=== FILE net/minecraft/client/renderer/texture/TextureAtlasSprite.java +--- BEFORE + this.framesTextureData.add((Object) null); +--- AFTER + this.framesTextureData.add((int[][]) null); + +=== FILE net/minecraft/client/renderer/tileentity/TileEntityRendererDispatcher.java +--- BEFORE + tileentityspecialrenderer = (TileEntitySpecialRenderer) this.getRenderer(oclass.getSuperclass()); +--- AFTER + tileentityspecialrenderer = (TileEntitySpecialRenderer) this.getRenderer((Class) (Class) oclass.getSuperclass()); + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + list = Lists.newArrayList(new Entity[]{entity}); +--- AFTER + list = Lists.newArrayList((T) entity); + +=== FILE net/minecraft/entity/Entity.java +--- BEFORE + set.add(entity); +--- AFTER + set.add((T) entity); + +=== FILE net/minecraft/entity/EntityList.java +--- BEFORE + EntityList.OLD_NAMES.add((Object) null); +--- AFTER + EntityList.OLD_NAMES.add((String) null); + +=== FILE net/minecraft/entity/ai/EntityAINearestAttackableTarget.java +--- BEFORE + }, this.targetEntitySelector); +--- AFTER + }, (Predicate) this.targetEntitySelector); + +=== FILE net/minecraft/item/ItemSlab.java +--- BEFORE + return this.doubleSlab.getDefaultState().withProperty(iproperty, comparable); +--- AFTER + return this.doubleSlab.getDefaultState().withProperty(iproperty, (T) comparable); + +# Packet.processPacket takes T, which here is the concrete handler subtype. +# Erasing the packet keeps the INetHandler it already is. +=== FILE net/minecraft/network/NetworkManager.java +--- BEFORE + packet.processPacket(this.packetListener); +--- AFTER + ((Packet) packet).processPacket(this.packetListener); + +=== FILE net/minecraft/tileentity/TileEntityPiston.java +--- BEFORE +TileEntityPiston.MOVING_ENTITY.set((Object) null); +--- AFTER +TileEntityPiston.MOVING_ENTITY.set((EnumFacing) null); + +=== FILE net/minecraft/network/datasync/EntityDataManager.java +--- BEFORE + list.add(new EntityDataManager.DataEntry<>(dataserializer.createKey(short0), dataserializer.read(packetbuffer))); +--- AFTER + list.add(new EntityDataManager.DataEntry(dataserializer.createKey(short0), dataserializer.read(packetbuffer))); + + + +=== FILE net/minecraft/client/renderer/OpenGlHelper.java +--- BEFORE + Object[] aprocessor = (Object[]) systeminfo.getHardware().getProcessor().getProcessors(); +--- AFTER + Object[] aprocessor = (Object[]) (new SystemInfo()).getHardware().getProcessor().getProcessors(); + +=== FILE net/minecraft/entity/ai/EntityAINearestAttackableTarget.java +--- BEFORE + this.targetEntity = this.taskOwner.world.getNearestAttackablePlayer( +--- AFTER + this.targetEntity = (EntityLivingBase) this.taskOwner.world.getNearestAttackablePlayer( + +=== FILE net/minecraft/network/datasync/EntityDataManager.java +--- BEFORE + list.add(new EntityDataManager.DataEntry(dataserializer.createKey(short0), dataserializer.read(packetbuffer))); +--- AFTER + list.add(new EntityDataManager.DataEntry((DataParameter) dataserializer.createKey(short0), dataserializer.read(packetbuffer))); + +=== FILE net/minecraft/network/datasync/EntityDataManager.java +--- BEFORE + entitydatamanager_dataentry.setValue(entitydatamanager_dataentry1.getValue()); +--- AFTER + entitydatamanager_dataentry.setValue((T) entitydatamanager_dataentry1.getValue()); + +=== FILE net/minecraft/util/HttpUtil.java +--- BEFORE + return listenablefuture; +--- AFTER + return (ListenableFuture) listenablefuture; + +=== FILE net/minecraft/util/JsonUtils.java +--- BEFORE + return gson.getAdapter(TypeToken.get(type)).read(jsonreader); +--- AFTER + return (T) gson.getAdapter(TypeToken.get(type)).read(jsonreader); + +=== FILE net/minecraft/util/NonNullList.java +--- BEFORE + return new NonNullList(Arrays.asList(aobject), e0); +--- AFTER + return new NonNullList((List) Arrays.asList(aobject), e0); + +=== FILE net/minecraft/util/math/Cartesian.java +--- BEFORE + return Arrays.asList((Object[]) aobject); +--- AFTER + return Arrays.asList((T[]) aobject); + + +# EntityPlayer is not imported here, so the type witness needs its full name. +=== FILE net/minecraft/client/renderer/ItemRenderer.java +--- BEFORE + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp); +--- AFTER + RenderPlayer renderplayer = (RenderPlayer) this.renderManager.getEntityRenderObject(entityplayersp); + +# The bundled oshi is 5.8.5, where CentralProcessor no longer has +# getProcessors(); getProcessorIdentifier() is the replacement that still yields +# a name for this label. +=== FILE net/minecraft/client/renderer/OpenGlHelper.java +--- BEFORE + Object[] aprocessor = (Object[]) (new SystemInfo()).getHardware().getProcessor().getProcessors(); +--- AFTER + Object[] aprocessor = new Object[]{String.valueOf((new SystemInfo()).getHardware().getProcessor().getProcessorIdentifier())}; + +# the parameter is final, so the fallback needs its own local +=== FILE net/minecraft/client/renderer/texture/TextureManager.java +--- BEFORE + itextureobject = TextureUtil.MISSING_TEXTURE; + this.mapTextureObjects.put(resourcelocation, itextureobject); +--- AFTER + ITextureObject missing = TextureUtil.MISSING_TEXTURE; + this.mapTextureObjects.put(resourcelocation, missing); + +# targetEntity is declared as T, the class's own type parameter +=== FILE net/minecraft/entity/ai/EntityAINearestAttackableTarget.java +--- BEFORE + this.targetEntity = (EntityLivingBase) this.taskOwner.world.getNearestAttackablePlayer( +--- AFTER + this.targetEntity = (T) this.taskOwner.world.getNearestAttackablePlayer( + + +# The anonymous class captures the loop variable, which a for-loop never lets be +# effectively final. Capturing a copy is what the original bytecode did. +=== FILE net/minecraft/client/particle/ParticleManager.java +--- BEFORE + crashreportcategory.addDetail("Particle Type", new ICrashReportDetail() { + public String call() throws Exception { + return i == 0 ? "MISC_TEXTURE" : (i == 1 ? "TERRAIN_TEXTURE" : (i == 3 ? "ENTITY_PARTICLE_TEXTURE" : "Unknown - " + i)); + } + }); +--- AFTER + final int layer = i; + crashreportcategory.addDetail("Particle Type", new ICrashReportDetail() { + public String call() throws Exception { + return layer == 0 ? "MISC_TEXTURE" : (layer == 1 ? "TERRAIN_TEXTURE" : (layer == 3 ? "ENTITY_PARTICLE_TEXTURE" : "Unknown - " + layer)); + } + }); + +# j is a mouse-Y scratch value reused a second time further down. The decompiler +# declared it final, which makes the second assignment an error. +=== FILE net/minecraft/client/renderer/EntityRenderer.java +--- BEFORE + final int j; +--- AFTER + int j; + +# j is assigned twice, so it is not effectively final, and the crash report +# class captures it. Capturing a copy keeps the report correct. +=== FILE net/minecraft/client/renderer/EntityRenderer.java +--- BEFORE + crashreportcategory.addDetail("Mouse location", new ICrashReportDetail() { + public String call() throws Exception { + return String.format("Scaled: (%d, %d). Absolute: (%d, %d)", i1, j, Mouse.getX(), Mouse.getY()); + } + }); +--- AFTER + final int scaledMouseY = j; + crashreportcategory.addDetail("Mouse location", new ICrashReportDetail() { + public String call() throws Exception { + return String.format("Scaled: (%d, %d). Absolute: (%d, %d)", i1, scaledMouseY, Mouse.getX(), Mouse.getY()); + } + }); + +# A blank final assigned in both try and catch is rejected: the catch may run +# after the try already stored a value. Computing into a plain local first and +# freezing it afterwards says the same thing and is legal. +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + final GameType gametype; + + try { + int i = Integer.parseInt(s); + + gametype = GameType.parseGameTypeWithDefault(i, GameType.NOT_SET); + } catch (Throwable throwable) { + gametype = GameType.parseGameTypeWithDefault(s, GameType.NOT_SET); + } +--- AFTER + GameType parsedGameType; + + try { + int i = Integer.parseInt(s); + + parsedGameType = GameType.parseGameTypeWithDefault(i, GameType.NOT_SET); + } catch (Throwable throwable) { + parsedGameType = GameType.parseGameTypeWithDefault(s, GameType.NOT_SET); + } + + final GameType gametype = parsedGameType; + +# s is rewritten when the argument is negated, so the predicate captures a copy. +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + if (s != null) { + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (!(entity instanceof EntityLivingBase)) { +--- AFTER + if (s != null) { + final String teamName = s; + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (!(entity instanceof EntityLivingBase)) { + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + return s1.equals(s) != flag; +--- AFTER + return s1.equals(teamName) != flag; + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + if (s != null) { + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + return entity != null && entity.getName().equals(s) != flag; + } + }); + } +--- AFTER + if (s != null) { + final String playerName = s; + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + return entity != null && entity.getName().equals(playerName) != flag; + } + }); + } + +# i and j are blank finals assigned in two separate blocks, and each predicate +# captures them. One pair of locals per block is what the original meant. +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + final int i; + final int j; + + if (map.containsKey(EntitySelector.ARGUMENT_ROTY_MIN) || map.containsKey(EntitySelector.ARGUMENT_ROTY_MAX)) { + i = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTY_MIN, 0)); + j = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTY_MAX, 359)); + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (entity == null) { + return false; + } else { + int k = MathHelper.wrapDegrees(MathHelper.floor(entity.rotationYaw)); + + return i > j ? k >= i || k <= j : k >= i && k <= j; + } + } + }); + } + + if (map.containsKey(EntitySelector.ARGUMENT_ROTX_MIN) || map.containsKey(EntitySelector.ARGUMENT_ROTX_MAX)) { + i = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTX_MIN, 0)); + j = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTX_MAX, 359)); + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (entity == null) { + return false; + } else { + int k = MathHelper.wrapDegrees(MathHelper.floor(entity.rotationPitch)); + + return i > j ? k >= i || k <= j : k >= i && k <= j; + } + } + }); + } +--- AFTER + if (map.containsKey(EntitySelector.ARGUMENT_ROTY_MIN) || map.containsKey(EntitySelector.ARGUMENT_ROTY_MAX)) { + final int minYaw = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTY_MIN, 0)); + final int maxYaw = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTY_MAX, 359)); + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (entity == null) { + return false; + } else { + int k = MathHelper.wrapDegrees(MathHelper.floor(entity.rotationYaw)); + + return minYaw > maxYaw ? k >= minYaw || k <= maxYaw : k >= minYaw && k <= maxYaw; + } + } + }); + } + + if (map.containsKey(EntitySelector.ARGUMENT_ROTX_MIN) || map.containsKey(EntitySelector.ARGUMENT_ROTX_MAX)) { + final int minPitch = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTX_MIN, 0)); + final int maxPitch = MathHelper.wrapDegrees(getInt(map, EntitySelector.ARGUMENT_ROTX_MAX, 359)); + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + if (entity == null) { + return false; + } else { + int k = MathHelper.wrapDegrees(MathHelper.floor(entity.rotationPitch)); + + return minPitch > maxPitch ? k >= minPitch || k <= maxPitch : k >= minPitch && k <= maxPitch; + } + } + }); + } + +=== FILE net/minecraft/command/EntitySelector.java +--- BEFORE + if (s != null) { + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + return entity == null ? false : ("".equals(s) ? entity.getTags().isEmpty() != flag : entity.getTags().contains(s) != flag); + } + }); + } +--- AFTER + if (s != null) { + final String tag = s; + list.add(new Predicate() { + public boolean apply(@Nullable Entity entity) { + return entity == null ? false : ("".equals(tag) ? entity.getTags().isEmpty() != flag : entity.getTags().contains(tag) != flag); + } + }); + } + +# FernFlower moved shutdownMinecraftApplet() into a finally on the game loop +# body. A continue inside a try runs the finally first, so the client shut +# down after one frame. javap on the original bytecode shows shutdown is +# only reached at loop exit (offset 103) and from the catch handlers. +=== FILE net/minecraft/client/Minecraft.java +--- BEFORE + public void run() { + this.running = true; + + CrashReport crashreport; + + try { + this.init(); + } catch (Throwable throwable) { + crashreport = CrashReport.makeCrashReport(throwable, "Initializing game"); + crashreport.makeCategory("Initialization"); + this.displayCrashReport(this.addGraphicsAndWorldToCrashReport(crashreport)); + return; + } + + while (true) { + try { + if (!this.running) { + break; + } + + if (!this.hasCrashed || this.crashReporter == null) { + try { + this.runGameLoop(); + } catch (OutOfMemoryError outofmemoryerror) { + this.freeMemory(); + this.displayGuiScreen(new GuiMemoryErrorScreen()); + System.gc(); + } + continue; + } + + this.displayCrashReport(this.crashReporter); + } catch (MinecraftError minecrafterror) { + break; + } catch (ReportedException reportedexception) { + this.addGraphicsAndWorldToCrashReport(reportedexception.getCrashReport()); + this.freeMemory(); + Minecraft.LOGGER.fatal("Reported exception thrown!", reportedexception); + this.displayCrashReport(reportedexception.getCrashReport()); + break; + } catch (Throwable throwable1) { + crashreport = this.addGraphicsAndWorldToCrashReport(new CrashReport("Unexpected error", throwable1)); + this.freeMemory(); + Minecraft.LOGGER.fatal("Unreported exception thrown!", throwable1); + this.displayCrashReport(crashreport); + break; + } finally { + this.shutdownMinecraftApplet(); + } + + return; + } + + } +--- AFTER + public void run() { + this.running = true; + + CrashReport crashreport; + + try { + this.init(); + } catch (Throwable throwable) { + crashreport = CrashReport.makeCrashReport(throwable, "Initializing game"); + crashreport.makeCategory("Initialization"); + this.displayCrashReport(this.addGraphicsAndWorldToCrashReport(crashreport)); + return; + } + + try { + while (true) { + if (!this.running) { + break; + } + + if (this.hasCrashed && this.crashReporter != null) { + this.displayCrashReport(this.crashReporter); + this.shutdownMinecraftApplet(); + return; + } + + try { + this.runGameLoop(); + } catch (OutOfMemoryError outofmemoryerror) { + this.freeMemory(); + this.displayGuiScreen(new GuiMemoryErrorScreen()); + System.gc(); + } + } + + this.shutdownMinecraftApplet(); + } catch (MinecraftError minecrafterror) { + this.shutdownMinecraftApplet(); + } catch (ReportedException reportedexception) { + this.addGraphicsAndWorldToCrashReport(reportedexception.getCrashReport()); + this.freeMemory(); + Minecraft.LOGGER.fatal("Reported exception thrown!", reportedexception); + this.displayCrashReport(reportedexception.getCrashReport()); + this.shutdownMinecraftApplet(); + } catch (Throwable throwable1) { + crashreport = this.addGraphicsAndWorldToCrashReport(new CrashReport("Unexpected error", throwable1)); + this.freeMemory(); + Minecraft.LOGGER.fatal("Unreported exception thrown!", throwable1); + this.displayCrashReport(crashreport); + this.shutdownMinecraftApplet(); + } + } + +=== FILE net/minecraft/entity/player/EntityPlayerMP.java +--- BEFORE + this.entityRemoveQueue.remove(entity.getEntityId()); +--- AFTER + this.entityRemoveQueue.remove(Integer.valueOf(entity.getEntityId())); + +=== FILE net/minecraft/client/Minecraft.java +--- BEFORE +this.renderGlobal.makeEntityOutlineShader(); + } + + private void populateSearchTreeManager() { +--- AFTER +net.rustnotmc.client.RsxBootstrap.start(this); + this.renderGlobal.makeEntityOutlineShader(); + } + + private void populateSearchTreeManager() { + +=== FILE net/minecraft/client/Minecraft.java +--- BEFORE +GlStateManager.popMatrix(); + this.profiler.startSection("root"); + this.updateDisplay(); +--- AFTER +GlStateManager.popMatrix(); + net.rustnotmc.client.RsxBootstrap.renderFrame(this); + this.profiler.startSection("root"); + this.updateDisplay(); + +=== FILE net/minecraft/client/gui/GuiScreen.java +--- BEFORE +public void sendChatMessage(String s, boolean flag) { + if (flag) { + this.mc.ingameGUI.getChatGUI().addToSentMessages(s); + } + + this.mc.player.sendChatMessage(s); + } +--- AFTER +public void sendChatMessage(String s, boolean flag) { + if (flag) { + this.mc.ingameGUI.getChatGUI().addToSentMessages(s); + } + + // a mod command scoped to the client is carried out here and stops here. + // Anything the server owns is left alone so it still reaches the server. + if (net.rustnotmc.client.RsxBootstrap.handleChatCommand(s)) { + return; + } + + this.mc.player.sendChatMessage(s); + } + +=== FILE net/minecraft/client/entity/EntityPlayerSP.java +--- BEFORE +public void onLivingUpdate() { + ++this.sprintingTicksLeft; +--- AFTER +public void onLivingUpdate() { + net.rustnotmc.client.RsxDebug.movement(this); + ++this.sprintingTicksLeft; + +=== FILE net/minecraft/entity/Entity.java +--- BEFORE +this.collidedHorizontally = d7 != d0 || d9 != d2; + this.collidedVertically = d1 != d1; + this.onGround = this.collidedVertically && d8 < 0.0D; +--- AFTER +this.collidedHorizontally = d7 != d0 || d9 != d2; + this.collidedVertically = d8 != d1; + this.onGround = this.collidedVertically && d8 < 0.0D; + +=== FILE net/minecraft/client/particle/Particle.java +--- BEFORE +this.onGround = d1 != d1 && d3 < 0.0D; + if (d0 != d0) { + this.motionX = 0.0D; + } + + if (d2 != d2) { + this.motionZ = 0.0D; + } +--- AFTER +this.onGround = originalY != d1 && originalY < 0.0D; + if (originalX != d0) { + this.motionX = 0.0D; + } + + if (originalZ != d2) { + this.motionZ = 0.0D; + } + +=== FILE net/minecraft/client/particle/Particle.java +--- BEFORE +double d3 = d1; + + if (this.canCollide) { +--- AFTER +// the motion before collision; the axis is only stopped when collision + // actually changed it, which a comparison against itself can never see + double originalX = d0; + double originalY = d1; + double originalZ = d2; + + if (this.canCollide) { + +=== FILE net/minecraft/entity/Entity.java +--- BEFORE +boolean flag = this.onGround || d1 != d1 && d1 < 0.0D; +--- AFTER +boolean flag = this.onGround || d8 != d1 && d8 < 0.0D; + +=== FILE net/minecraft/client/audio/SoundHandler.java +--- BEFORE +Map map = this.getSoundMap(iresource.getInputStream()); +--- AFTER +Map map = this.getSoundMap(iresource.getInputStream()); + net.rustnotmc.client.RsxDebug.sound("parsed=" + (map == null ? "null" : String.valueOf(map.size()))); + +=== FILE net/minecraft/client/audio/SoundHandler.java +--- BEFORE +List list = iresourcemanager.getAllResources(new ResourceLocation(s, "sounds.json")); +--- AFTER +List list = iresourcemanager.getAllResources(new ResourceLocation(s, "sounds.json")); + net.rustnotmc.client.RsxDebug.sound("domain=" + s + " resources=" + list.size()); + +=== FILE net/minecraft/client/audio/SoundHandler.java +--- BEFORE +this.soundRegistry.clearMap(); + Iterator iterator = iresourcemanager.getResourceDomains().iterator(); +--- AFTER +this.soundRegistry.clearMap(); + net.rustnotmc.client.RsxDebug.sound("domains=" + iresourcemanager.getResourceDomains()); + Iterator iterator = iresourcemanager.getResourceDomains().iterator(); diff --git a/game-fork/scripts/compile-game.ps1 b/game-fork/scripts/compile-game.ps1 new file mode 100644 index 0000000..f26977a --- /dev/null +++ b/game-fork/scripts/compile-game.ps1 @@ -0,0 +1,63 @@ +# Compiles the forked client from the existing sources. Fast iteration loop: +# fix, run this, look at the numbers. Use prepare-game.ps1 when the sources +# themselves need regenerating. +# +# ASCII only: PowerShell 5.1 reads a .ps1 without a byte order mark using the +# system ANSI code page. + +$ErrorActionPreference = 'Stop' + +$Fork = Split-Path -Parent $PSScriptRoot +if (-not $Fork) { $Fork = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Root = Split-Path -Parent $Fork +$Client = Join-Path $Root 'rsx-client' + +$Astral = Join-Path $env:APPDATA 'AstralRinthApp\meta' +$Src = Join-Path $Fork 'src' +$Out = Join-Path $Fork 'build\classes' +$Temp = Join-Path $env:TEMP 'opencode' + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$good = New-Object System.Collections.Generic.List[string] +foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '*.jar' -File)) { + if ($j.Length -le 100) { continue } + $ok = $false + try { $z = [System.IO.Compression.ZipFile]::OpenRead($j.FullName); $z.Dispose(); $ok = $true } catch { $ok = $false } + if ($ok) { $good.Add(($j.FullName -replace '\\', '/')) } +} + +# The fork calls into the mod loader, so the loader has to be visible to javac +# exactly as it is at runtime. Building the loader first is what keeps the two +# from drifting apart. +$Loader = Join-Path $Client 'dist\rsx-loader.jar' + +$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';' +$argf = Join-Path $Temp 'fork-build.txt' +$errf = Join-Path $Temp 'fork-build-errors.txt' +$sb = New-Object System.Text.StringBuilder +foreach ($a in @('-nowarn', '-proc:none', '-encoding', 'UTF-8', '--release', '17', '-d', ($Out -replace '\\', '/'))) { [void]$sb.AppendLine($a) } +[void]$sb.AppendLine('-cp'); [void]$sb.AppendLine('"' + $cp + '"') +Get-ChildItem $Src -Recurse -Filter '*.java' | ForEach-Object { [void]$sb.AppendLine(($_.FullName -replace '\\', '/')) } +Set-Content -LiteralPath $argf -Value $sb.ToString() -Encoding ASCII + +Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Out | Out-Null +$javac = (Get-Command javac).Source +& cmd /c "`"$javac`" @`"$argf`" -Xmaxerrs 10000 > `"$errf`" 2>&1" + +$errors = Get-Content -LiteralPath $errf | Select-String -Pattern ': error:' +$classes = (Get-ChildItem $Out -Recurse -Filter '*.class' -File -ErrorAction SilentlyContinue).Count + +if ($errors.Count -eq 0) { + Write-Host '' + Write-Host " FORK COMPILES CLEAN - $classes classes" -ForegroundColor Green + exit 0 +} + +$files = $errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | Sort-Object -Unique +Write-Host '' +Write-Host " $classes classes, $($errors.Count) errors in $($files.Count) files" -ForegroundColor Yellow +$errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | + Group-Object | Sort-Object Count -Descending | Select-Object -First 25 | + ForEach-Object { Write-Host (" {0,4} {1}" -f $_.Count, ($_.Name.Replace(($Src + '\'), ''))) -ForegroundColor Gray } \ No newline at end of file diff --git a/game-fork/scripts/prepare-game.ps1 b/game-fork/scripts/prepare-game.ps1 new file mode 100644 index 0000000..95aad90 --- /dev/null +++ b/game-fork/scripts/prepare-game.ps1 @@ -0,0 +1,173 @@ +# Builds the forked client from the verified vanilla jar. +# +# vanilla client -> MCP names -> source -> patches -> classes +# +# ASCII only: PowerShell 5.1 reads a .ps1 without a byte order mark using the +# system ANSI code page, which mangles UTF-8 and breaks the parser. +# +# Everything here is repeatable. Run it any time; nothing is edited by hand +# except game-fork\patches\decompiler-fixes.txt, which is re-applied at the end. + +$ErrorActionPreference = 'Stop' + +$Fork = Split-Path -Parent $PSScriptRoot +if (-not $Fork) { $Fork = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Root = Split-Path -Parent $Fork +$Client = Join-Path $Root 'rsx-client' + +$VanillaJar = 'G:\rsx-map\vanilla\client-1.12.2.jar' +$Astral = Join-Path $env:APPDATA 'AstralRinthApp\meta' +$Fernflower = 'G:\Paper-Dev\work\BuildData\bin\fernflower.jar' +$SpecialSource = 'G:\Paper-Dev\work\BuildData\bin\SpecialSource.jar' +$McpCs = 'G:\rsx-map\vanilla\mcp-stable' + +$Maps = Join-Path $Fork 'mappings' +$Classes = Join-Path $Fork 'classes' +$Src = Join-Path $Fork 'src' +$Out = Join-Path $Fork 'build\classes' +$Temp = Join-Path $env:TEMP 'opencode' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } +function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "missing $what at $path" } } + +New-Item -ItemType Directory -Force -Path $Maps, $Temp | Out-Null +Need $VanillaJar 'the verified vanilla client jar' +Need $Fernflower 'fernflower' +Need $SpecialSource 'SpecialSource' +Need $McpCs 'the MCP name CSVs' + +# 1. vanilla SRG +$srgZip = Join-Path $Maps 'mcp-1.12.2-srg.zip' +$srgDir = Join-Path $Maps 'mcp-srg-vanilla' +if (-not (Test-Path (Join-Path $srgDir 'joined.srg'))) { + Say 'downloading the vanilla 1.12.2 SRG' + Invoke-WebRequest -Uri 'https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.12.2/mcp-1.12.2-srg.zip' ` + -OutFile $srgZip -TimeoutSec 180 -UseBasicParsing + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($srgZip, $srgDir) +} + +# 2. merge SRG with the MCP CSVs into a single obf -> MCP SRG +$srg = Join-Path $Maps 'obf-to-mcp.srg' +Say 'merging SRG with the MCP CSVs' +& python (Join-Path $Client 'tools\merge_srg_mcp.py') ` + (Join-Path $srgDir 'joined.srg') $McpCs $srg +if ($LASTEXITCODE -ne 0) { throw 'srg merge failed' } + +# 3. remap the vanilla client +$named = Join-Path $Maps 'client-mcp-named.jar' +Say 'remapping the vanilla client to MCP names' +if (Test-Path $named) { Remove-Item $named -Force } +& java -Xmx4g -jar $SpecialSource -m $srg -i $VanillaJar -o $named +if (-not (Test-Path $named)) { throw 'remap produced nothing' } +Say " client-mcp-named.jar ($((Get-Item $named).Length) bytes)" + +# 4. unpack for the decompiler +Remove-Item $Classes -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Classes | Out-Null +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::ExtractToDirectory($named, $Classes) +Say " unpacked $((Get-ChildItem $Classes -Recurse -Filter '*.class' -File).Count) classes" + +# 5. decompile +# -dgs=1 decode generic signatures +# -rbr=1 drop bridge methods (otherwise every generic method appears twice +# and the compiler rejects the erasure clash) +# -rsy=1 drop synthetic members +Remove-Item $Src -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Src | Out-Null +Say 'decompiling (this is the slow part)' +$log = Join-Path $Temp 'fernflower.log' +& cmd /c "java -Xmx4g -jar `"$Fernflower`" -dgs=1 -rbr=1 -rsy=1 `"$Classes`" `"$Src`" > `"$log`" 2>&1" +Say " $((Get-ChildItem $Src -Recurse -Filter '*.java' -File).Count) source files" + +# 5b. copy in our own game-side code +# +# Everything above this line is regenerated from the vanilla jar and is wiped on +# every run. Our own classes under net/rustnotmc are not generated and must live +# outside src, or a rebuild deletes them and leaves the patches that call them +# pointing at nothing. They go in before the patches so a hand fix may edit them +# too. +$Overlay = Join-Path $Fork 'src-overlay' +if (Test-Path $Overlay) { + $copied = 0 + foreach ($f in (Get-ChildItem $Overlay -Recurse -Filter '*.java' -File)) { + $rel = $f.FullName.Substring($Overlay.Length).TrimStart('\') + $target = Join-Path $Src $rel + $dir = Split-Path -Parent $target + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + Copy-Item -LiteralPath $f.FullName -Destination $target -Force + $copied++ + } + Say " overlay: $copied source file(s) from src-overlay" +} + +# 6. re-apply the hand fixes +Say 'applying hand fixes' +& python (Join-Path $Client 'tools\apply_patches.py') $Src (Join-Path $Fork 'patches\decompiler-fixes.txt') +if ($LASTEXITCODE -ne 0) { Write-Host ' WARNING: some patches did not apply' -ForegroundColor Yellow } + +# 6b. the systematic rewrites, which would be hundreds of patch entries one by one +Say 'applying systematic rules' +& python (Join-Path $Client 'tools\fix_decompiler_rules.py') $Src + +# 7. compile, let the automated fixers converge, compile again. +# +# The fixers need javac's diagnostics as their input, and fixing one error can +# reveal the next, so this runs to a fixed point rather than once. It stops as +# soon as a round fails to reduce the count, which is the signal that the +# remaining errors need a human. +$argf = Join-Path $Temp 'fork-build.txt' +$errf = Join-Path $Temp 'fork-build-errors.txt' + +$good = New-Object System.Collections.Generic.List[string] +foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '*.jar' -File)) { + if ($j.Length -le 100) { continue } + $ok = $false + try { $z = [System.IO.Compression.ZipFile]::OpenRead($j.FullName); $z.Dispose(); $ok = $true } catch { $ok = $false } + if ($ok) { $good.Add(($j.FullName -replace '\\', '/')) } +} +# The fork calls into the mod loader, so the loader has to be visible to javac +# exactly as it is at runtime. +$Loader = Join-Path $Client 'dist\rsx-loader.jar' +$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';' +$sb = New-Object System.Text.StringBuilder +foreach ($a in @('-nowarn', '-proc:none', '-encoding', 'UTF-8', '--release', '17', '-d', ($Out -replace '\\', '/'))) { [void]$sb.AppendLine($a) } +[void]$sb.AppendLine('-cp'); [void]$sb.AppendLine('"' + $cp + '"') +Get-ChildItem $Src -Recurse -Filter '*.java' | ForEach-Object { [void]$sb.AppendLine(($_.FullName -replace '\\', '/')) } +Set-Content -LiteralPath $argf -Value $sb.ToString() -Encoding ASCII + +$javac = (Get-Command javac).Source +function Invoke-Compile { + Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Out | Out-Null + & cmd /c "`"$javac`" @`"$argf`" -Xmaxerrs 10000 > `"$errf`" 2>&1" + return (Get-Content -LiteralPath $errf | Select-String -Pattern ': error:').Count +} + +$count = Invoke-Compile +Say "compiled, $count errors" +$previous = [int]::MaxValue +for ($round = 1; $round -le 8 -and $count -gt 0; $round++) { + if ($count -ge $previous) { Say "no further progress, stopping"; break } + $previous = $count + Say "fix round $round" + & python (Join-Path $Client 'tools\fix_stale_locals.py') $errf --apply | Out-Null + & python (Join-Path $Client 'tools\fix_generic_casts.py') $errf --apply | Out-Null + & python (Join-Path $Client 'tools\fix_decompiler_rules.py') $Src | Out-Null + $count = Invoke-Compile + Say " -> $count errors" +} + +$errors = Get-Content -LiteralPath $errf | Select-String -Pattern ': error:' +$files = $errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | Sort-Object -Unique +Write-Host '' +if ($errors.Count -eq 0) { + Write-Host ' FORK COMPILES CLEAN' -ForegroundColor Green + Write-Host " $((Get-ChildItem $Out -Recurse -Filter '*.class' -File).Count) classes" +} else { + Write-Host " $((Get-ChildItem $Out -Recurse -Filter '*.class' -File).Count) classes, $($errors.Count) errors in $($files.Count) files" -ForegroundColor Yellow + $errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | + Group-Object | Sort-Object Count -Descending | Select-Object -First 15 | + ForEach-Object { Write-Host (" {0,4} {1}" -f $_.Count, ($_.Name.Replace(($Src + '\'), ''))) -ForegroundColor Gray } +} \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java new file mode 100644 index 0000000..b23a347 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java @@ -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 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 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); + } + } +} \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxClientServices.java b/game-fork/src-overlay/net/rustnotmc/client/RsxClientServices.java new file mode 100644 index 0000000..a35bcd1 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxClientServices.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxCommands.java b/game-fork/src-overlay/net/rustnotmc/client/RsxCommands.java new file mode 100644 index 0000000..44940bf --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxCommands.java @@ -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 entries = new LinkedHashMap(); + + @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; + } +} \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxDebug.java b/game-fork/src-overlay/net/rustnotmc/client/RsxDebug.java new file mode 100644 index 0000000..c4baa8b --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxDebug.java @@ -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; + } +} \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxHudSurface.java b/game-fork/src-overlay/net/rustnotmc/client/RsxHudSurface.java new file mode 100644 index 0000000..b6639a2 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxHudSurface.java @@ -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); + } +} \ No newline at end of file diff --git a/rsx-auth/LICENSE b/rsx-auth/LICENSE new file mode 100644 index 0000000..cf9d1ac --- /dev/null +++ b/rsx-auth/LICENSE @@ -0,0 +1,9 @@ +RustNotMC - rsx-auth +Copyright (c) 2026 RustNotMC. All rights reserved. + +This repository is proprietary and is not published. No part of it may be +copied, modified, redistributed or used to build a derivative work without +written permission from the copyright holder. + +Third-party components vendored under third_party\ remain under their own +licenses; see the notices in those directories. diff --git a/rsx-client/LICENSE b/rsx-client/LICENSE new file mode 100644 index 0000000..ea7e6d4 --- /dev/null +++ b/rsx-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RustNotMC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/rsx-client/scripts/build-mod.ps1 b/rsx-client/scripts/build-mod.ps1 new file mode 100644 index 0000000..2ac673e --- /dev/null +++ b/rsx-client/scripts/build-mod.ps1 @@ -0,0 +1,51 @@ +# Builds a mod jar from source. +# +# build-mod.ps1 [-Source ] [-Output ] +# +# Mods are ordinary jars with a descriptor at META-INF/rsxmod.properties. There +# is no annotation processor and no gradle: javac against the loader jar, then +# jar, then done. + +param( + [string]$Source = 'G:\RustNotMC\rsx-mods\rsxm-demo', + [string]$Output = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not $Output) { $Output = Join-Path $Source 'dist' } +$Loader = 'G:\RustNotMC\rsx-client\dist\rsx-loader.jar' +if (-not (Test-Path $Loader)) { throw "loader jar missing: $Loader" } + +$Name = Split-Path -Leaf $Source +$build = Join-Path $Source 'build' +$classes = Join-Path $build 'classes' +$jarPath = Join-Path $Output "$Name.jar" + +Remove-Item $classes -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $classes | Out-Null + +$mainSrc = Join-Path $Source 'src\main\java' +$mainRes = Join-Path $Source 'resources' + +$mainFiles = @(Get-ChildItem $mainSrc -Recurse -Filter '*.java' | ForEach-Object { $_.FullName }) +Write-Host " sources: $($mainFiles.Count)" + +& javac -encoding UTF-8 -nowarn -proc:none --release 17 -cp $Loader -d $classes @mainFiles +if ($LASTEXITCODE -ne 0) { throw "javac failed" } + +if (Test-Path $mainRes) { Copy-Item -Recurse -Force (Join-Path $mainRes '*') $classes } + +New-Item -ItemType Directory -Force -Path $Output | Out-Null +Remove-Item $jarPath -Force -ErrorAction SilentlyContinue + +# a mod is a library, never an executable, so there is no main class here +Push-Location $classes +& jar --create --file $jarPath -C $classes . +$jarExit = $LASTEXITCODE +Pop-Location +if ($jarExit -ne 0) { throw "jar failed with exit code $jarExit" } + +Write-Host '' +Write-Host " $jarPath ($((Get-Item $jarPath).Length) bytes)" +exit 0 \ No newline at end of file diff --git a/rsx-client/scripts/build.ps1 b/rsx-client/scripts/build.ps1 new file mode 100644 index 0000000..464f0dd --- /dev/null +++ b/rsx-client/scripts/build.ps1 @@ -0,0 +1,66 @@ +# Builds the loader into dist\. +# +# Only a JDK is required. No Gradle, no network: the loader is compiled with +# javac exactly the way the client will run it, with --release 17, because the +# game ships its own Java 8 runtime and anything newer simply will not load. +# +# dist\rsx-loader.jar the loader and the mod API +# +# NOTE: this file is ASCII on purpose. Windows PowerShell 5.1 reads a .ps1 that +# has no byte order mark using the system ANSI code page, which mangles UTF-8 +# and makes the parser choke on non-ASCII text. Keep script messages English. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { + Write-Host ' A JDK (javac) is required.' -ForegroundColor Red + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +# ---------------------------------------------------------------- loader +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainSrc = Join-Path $Repo 'src\main\java' +$mainFiles = @(Get-ChildItem $mainSrc -Recurse -Filter '*.java' | ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { + Write-Host ' No sources found.' -ForegroundColor Red + exit 1 +} + +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { + Write-Host ' Loader failed to compile.' -ForegroundColor Red + exit 1 +} +Say "loader compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC loader +Implementation-Version: 0.1.0 +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-loader.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { + Write-Host ' Could not package the jar.' -ForegroundColor Red + exit 1 +} +Say "rsx-loader.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" \ No newline at end of file diff --git a/rsx-client/scripts/run-game.ps1 b/rsx-client/scripts/run-game.ps1 new file mode 100644 index 0000000..a9c006f --- /dev/null +++ b/rsx-client/scripts/run-game.ps1 @@ -0,0 +1,63 @@ +# Launches the compiled fork as an ordinary client: no agent, no injection. +# +# run-game.ps1 [-Run ] [-Java ] [-ExtraArgs ] +# +# Stage first if run\classpath.txt is missing: +# python tools\stage_run.py ..\..\game-fork\build\classes + +param( + [string]$Run = 'G:\RustNotMC\run', + [string]$Java = '', + [string[]]$ExtraArgs = @(), + [int]$Width = 1280, + [int]$Height = 720 +) + +$ErrorActionPreference = 'Stop' +$root = 'G:\RustNotMC' + +if (-not (Test-Path (Join-Path $Run 'classpath.txt'))) { + throw "no classpath.txt in $Run - run stage_run.py first" +} + +if (-not $Java) { + $Java = Get-ChildItem "$env:APPDATA\AstralRinthApp\meta\java_versions" ` + -Directory | Where-Object { $_.Name -like 'zulu17*' } | ForEach-Object { + Get-ChildItem $_.FullName -Recurse -Filter 'java.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 } | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $Java) { throw 'no Java 17 found' } + +$assets = "$env:APPDATA\AstralRinthApp\meta\assets" +$cp = (Get-Content (Join-Path $Run 'classpath.txt') -Raw).Trim() +$natives = Join-Path $Run 'natives' + +# built as strings first: mixing '+' into an array literal folds the whole +# list into one element, because the comma binds tighter than the concatenation +$javaArgs = @() +$javaArgs += "-Djava.library.path=$natives" +$javaArgs += "-Dorg.lwjgl.librarypath=$natives" +$javaArgs += '-Dfile.encoding=UTF-8' +$javaArgs += '-cp' +$javaArgs += $cp +$javaArgs += 'net.minecraft.client.main.Main' +# singleplayer-only session: the client never validates this against Mojang +$javaArgs += '--username'; $javaArgs += 'Player' +$javaArgs += '--version'; $javaArgs += '1.12.2' +$javaArgs += '--gameDir'; $javaArgs += $Run +$javaArgs += '--assetsDir'; $javaArgs += $assets +$javaArgs += '--assetIndex'; $javaArgs += '1.12' +$javaArgs += '--uuid'; $javaArgs += '00000000000000000000000000000000' +$javaArgs += '--accessToken'; $javaArgs += '0' +$javaArgs += '--userType'; $javaArgs += 'legacy' +$javaArgs += '--versionType'; $javaArgs += 'release' +$javaArgs += '--width'; $javaArgs += "$Width" +$javaArgs += '--height'; $javaArgs += "$Height" +$javaArgs += $ExtraArgs + +Write-Host "java : $Java" +Write-Host "game : $Run" +Write-Host "assets : $assets" +Write-Host "args : $($javaArgs.Count)" +& $Java @javaArgs +exit $LASTEXITCODE \ No newline at end of file diff --git a/rsx-client/scripts/test.ps1 b/rsx-client/scripts/test.ps1 new file mode 100644 index 0000000..bc0c4e3 --- /dev/null +++ b/rsx-client/scripts/test.ps1 @@ -0,0 +1,43 @@ +# Compiles the loader plus the self test and runs it. +# +# ASCII only, for the same reason as build.ps1: PowerShell 5.1 misreads a .ps1 +# that has no byte order mark. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +New-Item -ItemType Directory -Force -Path $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } + +$testOut = Join-Path $Out 'test-classes' +Remove-Item $testOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $testOut | Out-Null + +$testFiles = @(Get-ChildItem (Join-Path $Repo 'src\test\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $mainOut -d $testOut $testFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Test failed to compile.' -ForegroundColor Red; exit 1 } +Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" + +& java -cp "$mainOut;$testOut" net.rustnotmc.test.SelfTest (Join-Path $Out 'selftest') +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host ' SELFTEST FAILED' -ForegroundColor Red + exit 1 +} + +Write-Host '' +Write-Host ' Self test passed.' -ForegroundColor Green \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/Rsx.java b/rsx-client/src/main/java/net/rustnotmc/api/Rsx.java new file mode 100644 index 0000000..d6aad0a --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/Rsx.java @@ -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 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 EventBus.Subscription on(Class type, java.util.function.Consumer body) { + return ctx.events().subscribe(ctx.modId(), type, body); + } + + public EventBus.Subscription on(Class type, int priority, + java.util.function.Consumer body) { + return ctx.events().subscribe(ctx.modId(), type, priority, body); + } + + public 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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/RustNotMcMod.java b/rsx-client/src/main/java/net/rustnotmc/api/RustNotMcMod.java new file mode 100644 index 0000000..b9d330c --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/RustNotMcMod.java @@ -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) { + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/ClientCommand.java b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientCommand.java new file mode 100644 index 0000000..6916dea --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientCommand.java @@ -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); +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/ClientHost.java b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientHost.java new file mode 100644 index 0000000..f6fc913 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientHost.java @@ -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 SCOPES = + new LinkedHashMap(); + + 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 registered() { + synchronized (SCOPES) { + return Collections.unmodifiableMap( + new LinkedHashMap(SCOPES)); + } + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/ClientServices.java b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientServices.java new file mode 100644 index 0000000..9a4c8d5 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/ClientServices.java @@ -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(); +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/CommandArgs.java b/rsx-client/src/main/java/net/rustnotmc/api/client/CommandArgs.java new file mode 100644 index 0000000..3fc4d75 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/CommandArgs.java @@ -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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/CommandScope.java b/rsx-client/src/main/java/net/rustnotmc/api/client/CommandScope.java new file mode 100644 index 0000000..fe2304f --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/CommandScope.java @@ -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; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/Commands.java b/rsx-client/src/main/java/net/rustnotmc/api/client/Commands.java new file mode 100644 index 0000000..1092d17 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/Commands.java @@ -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(); +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/Input.java b/rsx-client/src/main/java/net/rustnotmc/api/client/Input.java new file mode 100644 index 0000000..11bcc27 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/Input.java @@ -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); +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/Screen.java b/rsx-client/src/main/java/net/rustnotmc/api/client/Screen.java new file mode 100644 index 0000000..10c0c3e --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/Screen.java @@ -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(); +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/client/ScreenKind.java b/rsx-client/src/main/java/net/rustnotmc/api/client/ScreenKind.java new file mode 100644 index 0000000..a94c0f8 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/client/ScreenKind.java @@ -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; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/hud/Anchor.java b/rsx-client/src/main/java/net/rustnotmc/api/hud/Anchor.java new file mode 100644 index 0000000..5ecb6bc --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/hud/Anchor.java @@ -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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/hud/HudLayout.java b/rsx-client/src/main/java/net/rustnotmc/api/hud/HudLayout.java new file mode 100644 index 0000000..95fa6a3 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/hud/HudLayout.java @@ -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 SLOTS = new ArrayList(); + private static List 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 ordered() { + synchronized (LOCK) { + if (!dirty && sorted.size() == SLOTS.size()) { + return sorted; + } + List copy = new ArrayList(SLOTS); + Collections.sort(copy, new Comparator() { + 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 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; + } + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/api/hud/HudSurface.java b/rsx-client/src/main/java/net/rustnotmc/api/hud/HudSurface.java new file mode 100644 index 0000000..07338a3 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/api/hud/HudSurface.java @@ -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; + } + }; +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/Access.java b/rsx-client/src/main/java/net/rustnotmc/loader/Access.java new file mode 100644 index 0000000..91b3067 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/Access.java @@ -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 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 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 parseList(String raw) { + Set out = EnumSet.noneOf(Access.class); + if (raw == null) { + return out; + } + List bad = new ArrayList(); + List retired = new ArrayList(); + 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 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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/EventBus.java b/rsx-client/src/main/java/net/rustnotmc/loader/EventBus.java new file mode 100644 index 0000000..49f98d0 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/EventBus.java @@ -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 handler; + private volatile boolean cancelled; + + Subscription(EventBus bus, Class type, Consumer 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 body; + final int priority; + final int order; + final Subscription subscription; + + Handler(String mod, Class type, Consumer 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, List> byType = new ConcurrentHashMap, List>(); + private final Map, List> resolved = new ConcurrentHashMap, List>(); + private final Object lock = new Object(); + private int orderCounter; + private volatile boolean dirty = true; + + public Subscription subscribe(String modId, Class type, int priority, + Consumer body) { + if (type == null || body == null) { + throw new IllegalArgumentException("type and body are required"); + } + @SuppressWarnings("unchecked") + Consumer erased = (Consumer) body; + Subscription sub = new Subscription(this, type, erased); + Handler h = new Handler(modId == null ? "?" : modId, type, erased, priority, + orderCounter++, sub); + synchronized (lock) { + List list = byType.get(type); + if (list == null) { + list = new ArrayList(); + byType.put(type, list); + } + list.add(h); + dirty = true; + } + return sub; + } + + public Subscription subscribe(String modId, Class type, Consumer body) { + return subscribe(modId, type, NORMAL, body); + } + + private void remove(Subscription sub) { + synchronized (lock) { + List 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 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 postUntilCancelled(T event, java.util.function.Predicate 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 handlersFor(Class type) { + List cache = resolved.get(type); + if (cache != null && !dirty) { + return cache; + } + synchronized (lock) { + cache = new ArrayList(); + for (Map.Entry, List> e : byType.entrySet()) { + Class registered = e.getKey(); + if (registered.isAssignableFrom(type)) { + cache.addAll(e.getValue()); + } + } + java.util.Collections.sort(cache, new java.util.Comparator() { + 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 summary() { + Map out = new HashMap(); + synchronized (lock) { + for (Map.Entry, List> e : byType.entrySet()) { + out.put(e.getKey().getSimpleName(), e.getValue().size()); + } + } + return out; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/GameAccess.java b/rsx-client/src/main/java/net/rustnotmc/loader/GameAccess.java new file mode 100644 index 0000000..ed43540 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/GameAccess.java @@ -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. + * + * Caching. 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. + * + * Permission. 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> CLASSES = new ConcurrentHashMap>(); + private static final Map FIELDS = new ConcurrentHashMap(); + private static final Map METHODS = new ConcurrentHashMap(); + private static final Map REPORTED = new ConcurrentHashMap(); + + private static Properties mapping = new Properties(); + + private final String modId; + private final Set granted; + + public GameAccess(String modId, Set granted) { + this.modId = modId; + if (granted == null || granted.isEmpty()) { + this.granted = Collections.emptySet(); + } else { + Set 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 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 unresolved() { + List out = new ArrayList(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; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/Log.java b/rsx-client/src/main/java/net/rustnotmc/loader/Log.java new file mode 100644 index 0000000..18b6b2c --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/Log.java @@ -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); + } + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModContainer.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModContainer.java new file mode 100644 index 0000000..a97eebc --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModContainer.java @@ -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 dependents = new ArrayList(); + 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 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 + "]"; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModContext.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModContext.java new file mode 100644 index 0000000..4e4a813 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModContext.java @@ -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; + 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 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() { + 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 + ")"; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModDescriptor.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModDescriptor.java new file mode 100644 index 0000000..9e6ba50 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModDescriptor.java @@ -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. + * + *
+ * 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=>=1.0
+ * softdepends.rsxm-hud=*
+ * 
+ * + * 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; + private final List dependencies; + private final List 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, List dependencies, + List 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.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 collect(Properties p, String prefix) { + Set out = new LinkedHashSet(); + 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(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() { + return access; + } + + public List dependencies() { + return dependencies; + } + + public List 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 BY_ID = new Comparator() { + public int compare(ModDescriptor a, ModDescriptor b) { + return a.id.compareTo(b.id); + } + }; +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModEnv.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModEnv.java new file mode 100644 index 0000000..5e7e4bf --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModEnv.java @@ -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; + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModLoader.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModLoader.java new file mode 100644 index 0000000..8f8a9ca --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModLoader.java @@ -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 descriptors = new LinkedHashMap(); + private final Map loaded = new LinkedHashMap(); + private final List order = new ArrayList(); + private final List problems = new ArrayList(); + 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 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 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 done = new HashSet(); + Set visiting = new HashSet(); + List sorted = new ArrayList(); + List byId = new ArrayList(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())) { + 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 done, Set visiting, + List sorted, List 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 all = new ArrayList( + 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 REGISTERING = new ThreadLocal(); + + /** 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 urls = new ArrayList(); + 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 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 grant(ModDescriptor d) { + Set 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 mods() { + List out = new ArrayList(); + 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 problems() { + return Collections.unmodifiableList(problems); + } + + public List loadOrder() { + List out = new ArrayList(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 GRANTED = + Collections.synchronizedSet(new HashSet()); + + 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); + } + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/ModState.java b/rsx-client/src/main/java/net/rustnotmc/loader/ModState.java new file mode 100644 index 0000000..094f100 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/ModState.java @@ -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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/TickScheduler.java b/rsx-client/src/main/java/net/rustnotmc/loader/TickScheduler.java new file mode 100644 index 0000000..aa4b63f --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/TickScheduler.java @@ -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 tasks = new ArrayList(); + + /** Reused across ticks: the old code allocated a list every single tick. */ + private final List due = new ArrayList(); + + 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 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(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(); + } + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/Version.java b/rsx-client/src/main/java/net/rustnotmc/loader/Version.java new file mode 100644 index 0000000..d5cbd12 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/Version.java @@ -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 { + + 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(); + } +} \ No newline at end of file diff --git a/rsx-client/src/main/java/net/rustnotmc/loader/VersionConstraint.java b/rsx-client/src/main/java/net/rustnotmc/loader/VersionConstraint.java new file mode 100644 index 0000000..c4b6079 --- /dev/null +++ b/rsx-client/src/main/java/net/rustnotmc/loader/VersionConstraint.java @@ -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 + * >=1.0 at least this (also >, <=, <) + * 1.2.x 1.2.0 up to but not including 1.3.0 + * >=1.0,<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 out = new ArrayList(); + 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); + } + } + } +} \ No newline at end of file diff --git a/rsx-client/src/test/java/net/rustnotmc/test/SelfTest.java b/rsx-client/src/test/java/net/rustnotmc/test/SelfTest.java new file mode 100644 index 0000000..c897625 --- /dev/null +++ b/rsx-client/src/test/java/net/rustnotmc/test/SelfTest.java @@ -0,0 +1,615 @@ +package net.rustnotmc.test; + +import net.rustnotmc.api.client.ClientCommand; +import net.rustnotmc.api.client.ClientHost; +import net.rustnotmc.api.client.ClientServices; +import net.rustnotmc.api.client.CommandArgs; +import net.rustnotmc.api.client.CommandScope; +import net.rustnotmc.api.client.ScreenKind; +import net.rustnotmc.api.hud.Anchor; +import net.rustnotmc.api.hud.HudLayout; +import net.rustnotmc.api.hud.HudSurface; +import net.rustnotmc.loader.Access; +import net.rustnotmc.loader.EventBus; +import net.rustnotmc.loader.GameAccess; +import net.rustnotmc.loader.ModContainer; +import net.rustnotmc.loader.ModDescriptor; +import net.rustnotmc.loader.ModEnv; +import net.rustnotmc.loader.ModLoader; +import net.rustnotmc.loader.ModState; +import net.rustnotmc.loader.TickScheduler; +import net.rustnotmc.loader.Version; +import net.rustnotmc.loader.VersionConstraint; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Properties; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Self test for the loader. + * + * Real jars are built on the fly and loaded through the real loader, so this + * covers descriptor parsing, dependency ordering, lifecycle, permissions, the + * event bus, the scheduler and the HUD layout rather than just their internals. + */ +public final class SelfTest { + + private static int checks; + private static int failures; + private static final List FAILED = new ArrayList(); + + public static void main(String[] args) throws Exception { + File work = new File(args.length > 0 ? args[0] : "build/selftest"); + deleteTree(work); + File mods = new File(work, "mods"); + mods.mkdirs(); + + versions(); + constraints(); + access(); + clientApi(); + descriptors(mods); + loadOrder(mods); + lifecycle(mods); + failures(mods); + missingDependency(mods); + versionMismatch(mods); + cycles(mods); + permissions(mods); + events(mods); + scheduler(mods); + hud(); + + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failures); + if (!FAILED.isEmpty()) { + for (int i = 0; i < FAILED.size(); i++) { + System.out.println(" FAIL " + FAILED.get(i)); + } + System.exit(1); + } + System.out.println("OK"); + } + + // ------------------------------------------------------------ assertions + + private static void check(String what, boolean ok) { + checks++; + if (!ok) { + failures++; + FAILED.add(what); + System.out.println(" fail: " + what); + } + } + + private static void eq(String what, Object expected, Object actual) { + boolean ok = expected == null ? actual == null : expected.equals(actual); + checks++; + if (!ok) { + failures++; + FAILED.add(what + " (want " + expected + ", got " + actual + ")"); + System.out.println(" fail: " + what + " want=" + expected + " got=" + actual); + } + } + + // ----------------------------------------------------------------- tests + + private static void versions() { + eq("version parse", "1.2.3", Version.parse("1.2.3").toString()); + eq("version parse v prefix", "1.2.3", Version.parse("v1.2.3").toString()); + check("version trailing zero equals", Version.parse("1.2").same(Version.parse("1.2.0"))); + check("version shorter is lower", Version.parse("1.2").below(Version.parse("1.2.1"))); + check("version major wins", Version.parse("2.0").atLeast(Version.parse("1.9.9"))); + check("prerelease below release", + Version.parse("1.2.3-beta").below(Version.parse("1.2.3"))); + check("invalid version", !Version.parse("not-a-version").valid()); + check("null version invalid", !Version.parse(null).valid()); + } + + private static void constraints() { + eq("constraint any accepts", true, VersionConstraint.any().accepts(Version.parse("9.9"))); + eq("constraint exact", true, + VersionConstraint.parse("1.2.3").accepts(Version.parse("1.2.3"))); + eq("constraint exact rejects", false, + VersionConstraint.parse("1.2.3").accepts(Version.parse("1.2.4"))); + eq("constraint >=", true, + VersionConstraint.parse(">=1.2").accepts(Version.parse("1.3"))); + eq("constraint >=", false, + VersionConstraint.parse(">=1.2").accepts(Version.parse("1.1"))); + eq("constraint <", true, + VersionConstraint.parse("<2.0").accepts(Version.parse("1.9.9"))); + eq("constraint wildcard low", true, + VersionConstraint.parse("1.2.x").accepts(Version.parse("1.2.7"))); + eq("constraint wildcard high", false, + VersionConstraint.parse("1.2.x").accepts(Version.parse("1.3.0"))); + eq("constraint star", true, + VersionConstraint.parse("*").accepts(Version.parse("0.1"))); + eq("constraint compound ok", true, + VersionConstraint.parse(">=1.0,<2.0").accepts(Version.parse("1.5"))); + eq("constraint compound fails", false, + VersionConstraint.parse(">=1.0,<2.0").accepts(Version.parse("2.5"))); + eq("garbage constraint matches nothing", false, + VersionConstraint.parse("!!!").accepts(Version.parse("1.0"))); + eq("invalid version never matches", false, + VersionConstraint.parse(">=1.0").accepts(Version.parse("bad"))); + } + + private static void access() { + eq("access parse", Access.HUD, Access.byId("hud")); + eq("access parse case", Access.INPUT, Access.byId("INPUT")); + eq("access plural", Access.COMMANDS, Access.byId("commands")); + eq("access unknown", null, Access.byId("teleport")); + eq("access list size", 3, + Access.parseList("hud, input, commands").size()); + + // the old set promised world, player, network and text to every mod and + // checked none of them; they must stay gone rather than creep back + eq("world capability removed", null, Access.byId("world")); + eq("player capability removed", null, Access.byId("player")); + eq("network capability removed", null, Access.byId("network")); + eq("text capability removed", null, Access.byId("text")); + eq("a retired name is recognised as retired", true, Access.isRetired("world")); + eq("an unknown name is not retired", false, Access.isRetired("teleport")); + + // asking for a retired capability must grant nothing, not fall back + eq("retired capability grants nothing", 0, + Access.parseList("world,player,network").size()); + + check("reflection not default", !Access.REFLECTION.grantedByDefault()); + check("hud default", Access.HUD.grantedByDefault()); + check("core default", Access.CORE.grantedByDefault()); + check("input not default", !Access.INPUT.grantedByDefault()); + check("commands not default", !Access.COMMANDS.grantedByDefault()); + check("files not default", !Access.FILES.grantedByDefault()); + } + + /** + * The client API must work with no game attached, which is what keeps the + * loader buildable and testable on its own. + */ + private static void clientApi() { + check("no client attached", !ClientHost.present()); + ClientServices c = ClientHost.current(); + check("detached client handed out", c != null); + eq("detached screen kind", ScreenKind.UNKNOWN, c.screen().kind()); + eq("detached screen size", 0, c.screen().width()); + check("detached input reads nothing", !c.input().keyDown("key.sneak")); + check("detached mouse is not down", !c.input().mouseDown(0)); + check("denied input matches detached", !ClientHost.deniedInput().anyKeyDown()); + eq("detached tick", 0L, c.tick()); + + eq("command parses", "rsx", CommandArgs.parse("/rsx a b", "me").name()); + CommandArgs parsed = CommandArgs.parse("/rsx one two", "me"); + eq("command arg count", 2, parsed.count()); + eq("command arg 0", "one", parsed.arg(0, "?")); + eq("command missing arg falls back", "?", parsed.arg(9, "?")); + eq("command int arg", 12, CommandArgs.parse("/x 12", "me").argInt(0, -1)); + eq("command non-int falls back", -1, CommandArgs.parse("/x abc", "me").argInt(0, -1)); + eq("command sender", "me", parsed.sender()); + eq("command toString", "/rsx one two", parsed.toString()); + + check("client scope runs locally", CommandScope.CLIENT.clientExecutes()); + check("server scope does not run locally", !CommandScope.SERVER.clientExecutes()); + check("both scope is server decided", CommandScope.BOTH.serverDecides()); + eq("scope by name", CommandScope.BOTH, CommandScope.byName("both", null)); + + check("detached commands refuse registration", + !c.commands().register("x", new ClientCommand() { + public void run(CommandArgs a) { + } + }, CommandScope.CLIENT)); + eq("detached command count", 0, c.commands().count()); + } + + private static void descriptors(File mods) throws Exception { + File jar = modJar(mods, "desc-only.jar", new Properties() {{ + setProperty("id", "desc"); + setProperty("version", "2.5.1"); + setProperty("entrypoint", "net.rustnotmc.test.TestMods$LibMod"); + }}); + ModDescriptor d = ModDescriptor.load(jar); + check("descriptor read", d != null); + eq("descriptor id", "desc", d.id()); + eq("descriptor version", "2.5.1", d.version().toString()); + eq("descriptor name defaults to id", "desc", d.name()); + eq("descriptor env defaults to both", ModEnv.BOTH, d.env()); + eq("descriptor api default", 1, d.apiVersion()); + + File noEntry = modJar(mods, "desc-noentry.jar", new Properties() {{ + setProperty("id", "broken"); + }}); + eq("descriptor without entrypoint is rejected", null, ModDescriptor.load(noEntry)); + + File badId = modJar(mods, "desc-badid.jar", new Properties() {{ + setProperty("id", "Bad Id!"); + setProperty("entrypoint", "x.Y"); + }}); + eq("descriptor with illegal id is rejected", null, ModDescriptor.load(badId)); + + eq("env client", ModEnv.CLIENT, ModEnv.parse("client")); + eq("env server", ModEnv.SERVER, ModEnv.parse("SERVER")); + eq("env both", ModEnv.BOTH, ModEnv.parse("anything")); + check("client mod runs on client", ModEnv.CLIENT.runsOn(ModEnv.CLIENT)); + check("client mod does not run on server", !ModEnv.CLIENT.runsOn(ModEnv.SERVER)); + check("both runs everywhere", ModEnv.BOTH.runsOn(ModEnv.SERVER)); + + // dependencies parsed out of the properties + Properties p = new Properties(); + p.setProperty("id", "withdeps"); + p.setProperty("entrypoint", "x.Y"); + p.setProperty("depends.core", ">=1.0"); + p.setProperty("depends.other", "*"); + p.setProperty("softdepends.optional", "1.2"); + ModDescriptor wd = ModDescriptor.from(p, null); + eq("hard dependency count", 2, wd.dependencies().size()); + eq("soft dependency count", 1, wd.softDependencies().size()); + eq("dependency keeps its text", "core >=1.0", wd.dependencies().get(0).toString()); + } + + private static void loadOrder(File mods) throws Exception { + File dir = new File(mods, "order"); + dir.mkdirs(); + // a needs b, b needs nothing; ids chosen so alphabetical order is the wrong one + jar(dir, "zzz-a.jar", "aaa", "0.1.0", "net.rustnotmc.test.TestMods$NeedsLib", + props("depends.bbb", ">=1.0.0")); + jar(dir, "mmm-b.jar", "bbb", "1.2.0", "net.rustnotmc.test.TestMods$LibMod", null); + + ModLoader l = new ModLoader(dir); + eq("discovered", 2, l.discover()); + check("resolve ok", l.resolve()); + List order = l.loadOrder(); + eq("load order size", 2, order.size()); + eq("dependency loads first", "bbb", order.get(0)); + eq("dependent loads second", "aaa", order.get(1)); + } + + private static void lifecycle(File mods) throws Exception { + File dir = new File(mods, "life"); + dir.mkdirs(); + jar(dir, "a.jar", "aaa", "0.1.0", "net.rustnotmc.test.TestMods$NeedsLib", + props("depends.bbb", ">=1.0.0")); + jar(dir, "b.jar", "bbb", "1.2.0", "net.rustnotmc.test.TestMods$LibMod", null); + + TestMods.Trace.clear(); + ModLoader l = new ModLoader(dir); + l.discover(); + l.resolve(); + eq("two mods started", 2, l.loadAll()); + + String started = TestMods.Trace.joined(); + check("library constructed first", started.indexOf("lib:construct") < started.indexOf("needs:load")); + check("everything enabled", started.endsWith("lib:enable,needs:enable") + || started.contains("lib:enable") && started.contains("needs:enable")); + + ModContainer lib = l.mod("bbb"); + eq("state enabled", ModState.ENABLED, lib.state()); + eq("context id", "bbb", lib.context().modId()); + check("core granted", lib.context().has(Access.CORE)); + + l.unloadAll(); + String stopped = TestMods.Trace.joined(); + check("dependent disables before its library", + stopped.indexOf("needs:disable") < stopped.indexOf("lib:disable")); + check("unload happens last", + stopped.indexOf("needs:unload") < stopped.indexOf("lib:unload")); + eq("state unloaded", ModState.UNLOADED, lib.state()); + } + + private static void failures(File mods) throws Exception { + File dir = new File(mods, "fail"); + dir.mkdirs(); + jar(dir, "good.jar", "good", "0.1.0", "net.rustnotmc.test.TestMods$LibMod", null); + jar(dir, "bad.jar", "bad", "0.1.0", "net.rustnotmc.test.TestMods$BadMod", null); + + TestMods.Trace.clear(); + ModLoader l = new ModLoader(dir); + l.discover(); + l.resolve(); + l.loadAll(); + + eq("failing mod is marked failed", ModState.FAILED, l.mod("bad").state()); + eq("good mod still enabled", ModState.ENABLED, l.mod("good").state()); + check("bad mod never enabled", !TestMods.Trace.joined().contains("bad:enable")); + } + + private static void missingDependency(File mods) throws Exception { + File dir = new File(mods, "missing"); + dir.mkdirs(); + jar(dir, "n.jar", "n", "0.1.0", "net.rustnotmc.test.TestMods$NeedsMissing", + props("depends.ghost", ">=1.0.0")); + + ModLoader l = new ModLoader(dir); + l.discover(); + check("missing dependency fails resolution", !l.resolve()); + eq("problem reported", 1, l.problems().size()); + check("problem names the dependency", + l.problems().get(0).contains("ghost")); + } + + private static void versionMismatch(File mods) throws Exception { + File dir = new File(mods, "mismatch"); + dir.mkdirs(); + jar(dir, "lib.jar", "lib", "1.0.0", "net.rustnotmc.test.TestMods$LibMod", null); + jar(dir, "new.jar", "new", "0.1.0", "net.rustnotmc.test.TestMods$NeedsNewerLib", + props("depends.lib", ">=2.0.0")); + + ModLoader l = new ModLoader(dir); + l.discover(); + check("version mismatch fails resolution", !l.resolve()); + check("problem mentions the version", + l.problems().toString().contains("2.0.0")); + } + + private static void cycles(File mods) throws Exception { + File dir = new File(mods, "cycle"); + dir.mkdirs(); + jar(dir, "a.jar", "ca", "1.0.0", "net.rustnotmc.test.TestMods$CycleA", + props("depends.cb", "*")); + jar(dir, "b.jar", "cb", "1.0.0", "net.rustnotmc.test.TestMods$CycleB", + props("depends.ca", "*")); + + ModLoader l = new ModLoader(dir); + l.discover(); + check("cycle fails resolution", !l.resolve()); + check("problem mentions a cycle", + l.problems().toString().toLowerCase().contains("cycle")); + } + + private static void permissions(File mods) throws Exception { + File dir = new File(mods, "perm"); + dir.mkdirs(); + jar(dir, "rogue.jar", "rogue", "0.1.0", "net.rustnotmc.test.TestMods$RogueMod", null); + // this one explicitly asks for reflection in its descriptor, which must + // still not be enough on its own + jar(dir, "reflect.jar", "reflect", "0.1.0", + "net.rustnotmc.test.TestMods$ReflectiveMod", + props("access", "reflection, world")); + + TestMods.RogueMod.refused = false; + TestMods.ReflectiveMod.reflectionHeld = true; + + ModLoader l = new ModLoader(dir); + l.discover(); + l.resolve(); + l.loadAll(); + + check("mod without the hud capability cannot register an overlay", + TestMods.RogueMod.refused); + check("reflection is never granted by default", + !TestMods.ReflectiveMod.reflectionHeld); + l.unloadAll(); + } + + private static void events(File mods) throws Exception { + File dir = new File(mods, "events"); + dir.mkdirs(); + jar(dir, "ev.jar", "ev", "0.1.0", "net.rustnotmc.test.TestMods$EventMod", null); + + TestMods.EventMod.ORDER.clear(); + ModLoader l = new ModLoader(dir); + l.discover(); + l.resolve(); + l.loadAll(); + + l.events().post(new TestMods.Bang()); + + List order = TestMods.EventMod.ORDER; + eq("three handlers ran", 3, order.size()); + eq("highest priority runs first", "highest", order.get(0)); + eq("throwing handler did not stop the rest", "lowest", order.get(2)); + check("throwing handler itself ran", order.contains("throws")); + + // direct bus checks + EventBus bus = new EventBus(); + final List seen = new ArrayList(); + EventBus.Subscription s = bus.subscribe("t", TestMods.Bang.class, + e -> seen.add("x")); + eq("handler count", 1, bus.handlerCount(TestMods.Bang.class)); + bus.post(new TestMods.Bang()); + eq("handler ran", 1, seen.size()); + s.cancel(); + bus.post(new TestMods.Bang()); + eq("cancelled handler did not run", 1, seen.size()); + l.unloadAll(); + } + + private static void scheduler(File mods) { + TickScheduler s = new TickScheduler(); + final int[] counter = {0}; + + TickScheduler.Task t = s.post("t", () -> counter[0]++); + eq("nothing runs before a tick", 0, counter[0]); + s.tick(); + eq("posted task ran", 1, counter[0]); + + final int[] repeats = {0}; + TickScheduler.Task r = s.everyTicks("t", 3, () -> repeats[0]++); + for (int i = 0; i < 9; i++) { + s.tick(); + } + eq("every 3 ticks ran 3 times", 3, repeats[0]); + r.cancel(); + for (int i = 0; i < 9; i++) { + s.tick(); + } + eq("cancelled task stopped", 3, repeats[0]); + + // a task that always throws gets dropped rather than flooding the log + final int[] attempts = {0}; + s.everyTicks("t", 1, () -> { + attempts[0]++; + throw new IllegalStateException("on purpose"); + }); + for (int i = 0; i < 20; i++) { + s.tick(); + } + check("failing task was dropped", attempts[0] <= 6); + s.shutdown(); + eq("tick counter advanced", 39L, s.tickCount()); + } + + private static void hud() { + HudLayout.clear(); + final List painted = new ArrayList(); + + HudSurface surface = new HudSurface() { + public boolean available() { + return true; + } + + public int width() { + return 800; + } + + public int height() { + return 600; + } + + public float scale() { + return 2f; + } + + public void rect(float x, float y, float w, float h, int rgb, float a) { + } + + public void text(float x, float y, String v, int rgb, float a) { + painted.add(v + "@" + x + "," + y); + } + + public float textWidth(String v) { + return v.length() * 6f; + } + + public int textHeight() { + return 9; + } + }; + + HudLayout.add("top", draw("top"), Anchor.TOP_LEFT, 100, 0, 0); + HudLayout.add("bottom", draw("bottom"), Anchor.BOTTOM_LEFT, 100, 0, 0); + HudLayout.add("over", draw("over"), Anchor.TOP_LEFT, 500, 0, 0); + + HudLayout.render(surface); + + eq("three overlays drawn", 3, painted.size()); + eq("top left sits at the origin", "top@0.0,0.0", painted.get(0)); + eq("bottom left uses the screen height", "bottom@0.0,591.0", painted.get(1)); + eq("higher layer draws last", "over@0.0,0.0", painted.get(2)); + + HudLayout.clear(); + eq("cleared", 0, HudLayout.size()); + + eq("anchor centre", 390, Anchor.TOP_CENTER.x(800, 20)); + eq("anchor right", 780, Anchor.TOP_RIGHT.x(800, 20)); + eq("anchor centre y", 295, Anchor.CENTER.y(600, 10)); + eq("anchor by name", Anchor.BOTTOM_RIGHT, Anchor.byName("bottom_right", null)); + eq("anchor by legacy name", Anchor.TOP_LEFT, Anchor.byName("left-top", null)); + eq("anchor fallback on garbage", Anchor.TOP_LEFT, + Anchor.byName("nonsense", Anchor.TOP_LEFT)); + } + + private static HudLayout.Draw draw(final String label) { + return new HudLayout.Draw() { + public int width(HudSurface hud) { + return label.length() * 6; + } + + public int height(HudSurface hud) { + return 9; + } + + public void draw(HudSurface hud, int x, int y, int w, int h) { + hud.text(x, y, label, 0xFFFFFF, 1f); + } + }; + } + + // ------------------------------------------------------------- jar making + + private static Properties props(String key, String value) { + Properties p = new Properties(); + p.setProperty(key, value); + return p; + } + + private static File modJar(File dir, String name, Properties p) throws IOException { + JarOutputStream out = new JarOutputStream(new FileOutputStream(new File(dir, name))); + out.putNextEntry(new JarEntry("META-INF/rsxmod.properties")); + p.store(out, null); + out.closeEntry(); + out.close(); + return new File(dir, name); + } + + private static File jar(File dir, String fileName, String id, String version, + String entrypoint, Properties extra) throws IOException { + File target = new File(dir, fileName); + Properties p = new Properties(); + p.setProperty("id", id); + p.setProperty("version", version); + p.setProperty("entrypoint", entrypoint); + p.setProperty("env", "CLIENT"); + if (extra != null) { + for (Enumeration e = extra.propertyNames(); e.hasMoreElements(); ) { + String k = (String) e.nextElement(); + p.setProperty(k, extra.getProperty(k)); + } + } + JarOutputStream out = new JarOutputStream(new FileOutputStream(target)); + out.putNextEntry(new JarEntry("META-INF/rsxmod.properties")); + p.store(out, null); + out.closeEntry(); + if (entrypoint != null && entrypoint.length() > 0) { + String simple = entrypoint.substring(entrypoint.lastIndexOf('.') + 1); + copyClass(out, simple.replace('$', '$') + ".class"); + } + out.close(); + return target; + } + + /** Copies a compiled test class out of the test output directory. */ + private static void copyClass(JarOutputStream out, String fileName) throws IOException { + String resource = "net/rustnotmc/test/" + fileName; + InputStream in = SelfTest.class.getClassLoader().getResourceAsStream(resource); + if (in == null) { + return; + } + out.putNextEntry(new ZipEntry(resource)); + copy(in, out); + out.closeEntry(); + in.close(); + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + + private static void deleteTree(File f) { + if (f == null || !f.exists()) { + return; + } + if (f.isDirectory()) { + File[] kids = f.listFiles(); + if (kids != null) { + for (int i = 0; i < kids.length; i++) { + deleteTree(kids[i]); + } + } + } + f.delete(); + } +} \ No newline at end of file diff --git a/rsx-client/src/test/java/net/rustnotmc/test/TestMods.java b/rsx-client/src/test/java/net/rustnotmc/test/TestMods.java new file mode 100644 index 0000000..3aba520 --- /dev/null +++ b/rsx-client/src/test/java/net/rustnotmc/test/TestMods.java @@ -0,0 +1,184 @@ +package net.rustnotmc.test; + +import net.rustnotmc.api.Rsx; +import net.rustnotmc.api.RustNotMcMod; +import net.rustnotmc.loader.Access; +import net.rustnotmc.loader.EventBus; + +/** + * Mods the self test packages into real jars. + * + * They are nested classes on purpose: the class files are easy to name and copy + * when building a test jar, and they keep the whole suite in one place. + */ +public final class TestMods { + + private TestMods() { + } + + /** Records what the loader did, so the test can assert on the order. */ + public static final class Trace { + public static final java.util.List EVENTS = + java.util.Collections.synchronizedList(new java.util.ArrayList()); + + public static void clear() { + EVENTS.clear(); + } + + public static String joined() { + return String.join(",", EVENTS); + } + } + + /** Plain mod, no dependencies, needs the HUD capability. */ + public static class LibMod implements RustNotMcMod { + public Rsx rsx; + + public void onConstruct(Rsx ctx) { + this.rsx = ctx; + Trace.EVENTS.add("lib:construct"); + } + + public void onLoad() { + Trace.EVENTS.add("lib:load"); + } + + public void onEnable() { + Trace.EVENTS.add("lib:enable"); + } + + public void onDisable() { + Trace.EVENTS.add("lib:disable"); + } + + public void onUnload() { + Trace.EVENTS.add("lib:unload"); + } + } + + /** Depends on the library, so it must load after it. */ + public static class NeedsLib implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("needs:load"); + } + + public void onEnable() { + Trace.EVENTS.add("needs:enable"); + } + + public void onDisable() { + Trace.EVENTS.add("needs:disable"); + } + + public void onUnload() { + Trace.EVENTS.add("needs:unload"); + } + } + + /** Throws during load; must fail alone and leave the others running. */ + public static class BadMod implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("bad:load"); + throw new IllegalStateException("on purpose"); + } + + public void onEnable() { + Trace.EVENTS.add("bad:enable"); + } + } + + /** Depends on something that is never installed. */ + public static class NeedsMissing implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("missing:load"); + } + } + + /** Asks for a version of the library that is not installed. */ + public static class NeedsNewerLib implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("newer:load"); + } + } + + /** Two mods that need each other. */ + public static class CycleA implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("a:load"); + } + } + + public static class CycleB implements RustNotMcMod { + public void onLoad() { + Trace.EVENTS.add("b:load"); + } + } + + /** Declares no capabilities and tries to draw anyway. */ + public static class RogueMod implements RustNotMcMod { + public static boolean refused; + + public Rsx ctx; + + public void onConstruct(Rsx c) { + this.ctx = c; + } + + public void onEnable() { + refused = ctx.hud("rogue", new net.rustnotmc.api.hud.HudLayout.Draw() { + public int width(net.rustnotmc.api.hud.HudSurface hud) { + return 10; + } + + public int height(net.rustnotmc.api.hud.HudSurface hud) { + return 10; + } + + public void draw(net.rustnotmc.api.hud.HudSurface hud, int x, int y, + int w, int h) { + // must never run + } + }, net.rustnotmc.api.hud.Anchor.TOP_LEFT) == null; + } + } + + /** Requests raw reflection; the loader must not grant it. */ + public static class ReflectiveMod implements RustNotMcMod { + public static boolean reflectionHeld; + + public Rsx ctx; + + public void onConstruct(Rsx c) { + this.ctx = c; + } + + public void onEnable() { + reflectionHeld = ctx.has(Access.REFLECTION); + } + } + + /** Registers handlers that throw, to prove one bad mod does not silence others. */ + public static class EventMod implements RustNotMcMod { + public static final java.util.List ORDER = + java.util.Collections.synchronizedList(new java.util.ArrayList()); + + public Rsx ctx; + + public void onConstruct(Rsx c) { + this.ctx = c; + } + + public void onEnable() { + ctx.on(Bang.class, EventBus.LOWEST, e -> ORDER.add("lowest")); + ctx.on(Bang.class, e -> { + ORDER.add("throws"); + throw new IllegalStateException("on purpose"); + }); + ctx.on(Bang.class, EventBus.HIGHEST, e -> ORDER.add("highest")); + } + } + + public static class Bang { + public int hits; + } +} \ No newline at end of file diff --git a/rsx-client/third_party/asm/asm-9.7.jar b/rsx-client/third_party/asm/asm-9.7.jar new file mode 100644 index 0000000..fee9b02 Binary files /dev/null and b/rsx-client/third_party/asm/asm-9.7.jar differ diff --git a/rsx-client/third_party/asm/asm-commons-9.7.jar b/rsx-client/third_party/asm/asm-commons-9.7.jar new file mode 100644 index 0000000..6a7d3a1 Binary files /dev/null and b/rsx-client/third_party/asm/asm-commons-9.7.jar differ diff --git a/rsx-client/third_party/asm/asm-tree-9.7.jar b/rsx-client/third_party/asm/asm-tree-9.7.jar new file mode 100644 index 0000000..bbb3ca3 Binary files /dev/null and b/rsx-client/third_party/asm/asm-tree-9.7.jar differ diff --git a/rsx-client/tools/add_patch.py b/rsx-client/tools/add_patch.py new file mode 100644 index 0000000..4492f0e --- /dev/null +++ b/rsx-client/tools/add_patch.py @@ -0,0 +1,53 @@ +"""Append a hand fix to the patch file so it survives a re-decompile. + +Keeping every fix in one reviewed file, rather than editing generated source, +is what makes prepare-game.ps1 reproducible: the tree is thrown away and rebuilt +on every run, so a fix that is not recorded simply disappears. + + python add_patch.py + +The before/after files hold the exact text to replace and must match the +decompiled output byte for byte. +""" + +import io +import os +import sys + + +def main(): + patch_file, java_path, before_file, after_file = sys.argv[1:5] + with io.open(before_file, encoding='utf-8') as handle: + before = handle.read().rstrip('\n') + with io.open(after_file, encoding='utf-8') as handle: + after = handle.read().rstrip('\n') + + if not before or not after: + print('ERROR: before/after must not be empty') + return 1 + if before == after: + print('ERROR: before and after are identical') + return 1 + + existing = '' + if os.path.isfile(patch_file): + with io.open(patch_file, encoding='utf-8') as handle: + existing = handle.read() + + header = '=== FILE %s\n' % java_path + if header + '--- BEFORE\n' + before + '\n--- AFTER\n' in existing: + print('patch already present') + return 0 + + with io.open(patch_file, 'a', encoding='utf-8') as handle: + if existing and not existing.endswith('\n'): + handle.write('\n') + handle.write('\n' + header) + handle.write('--- BEFORE\n' + before + '\n') + handle.write('--- AFTER\n' + after + '\n') + print('appended patch for %s' % java_path) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) \ No newline at end of file diff --git a/rsx-client/tools/apply_patches.py b/rsx-client/tools/apply_patches.py new file mode 100644 index 0000000..e926dcd --- /dev/null +++ b/rsx-client/tools/apply_patches.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Re-apply hand fixes to a freshly decompiled tree. + +Decompiling is repeatable, our corrections to its output are not. Every manual +fix therefore lives in a patch file next to this script and is re-applied after +each decompile, so regenerating the tree never silently loses work. + +Patch format, deliberately boring: + + === FILE net/minecraft/block/BlockPortal.java + --- BEFORE + + --- AFTER + + +and for a whole file: + + === FILE net/minecraft/util/IObjectIntIterable.java + --- REPLACE + + +Comments start with '#'. Blank lines are ignored outside a block. A patch that +matches nothing is reported as a failure, because a silently skipped patch is +worse than a loud one: the tree looks right and is not. +""" + +import argparse +import os +import sys + + +def _trim(buf): + """Drops trailing blank and comment lines from a section buffer. + + A comment or a blank line always separates one patch from the next, so + without this a section would swallow the comment introducing the patch that + follows it and then never match. + """ + out = list(buf) + while out and (not out[-1].strip() or out[-1].lstrip().startswith('#')): + out.pop() + return out + + +def parse(path): + """Returns {relative_path: [(before, after, whole_file), ...]}. + + A file may carry several patches, so each path maps to a list rather than a + single entry: two patches for one file are two patches, not one that + overwrites the other. + """ + patches = {} + state = {'file': None, 'section': None, 'before': [], 'after': []} + + def flush(): + if state['file']: + if state['section'] == 'replace': + entry = (None, '\n'.join(_trim(state['after'])), True) + else: + entry = ('\n'.join(_trim(state['before'])), + '\n'.join(_trim(state['after'])), False) + patches.setdefault(state['file'], []).append(entry) + state['file'] = None + state['section'] = None + state['before'] = [] + state['after'] = [] + + with open(path, encoding='utf-8') as handle: + for raw in handle: + line = raw.rstrip('\n').rstrip('\r') + + if line.startswith('=== FILE '): + flush() + state['file'] = line[len('=== FILE '):].strip() + continue + + if line in ('--- BEFORE', '--- AFTER', '--- REPLACE'): + if not state['file']: + raise SystemExit('%s appears before any === FILE' % line) + state['section'] = line[4:].strip().lower() + continue + + if state['file'] and state['section'] == 'replace': + # A REPLACE block holds the whole file, so it would otherwise run + # on into whatever follows. Java has no line directives, so a line + # starting with '#' is safe to treat as the end of the block. + if line.startswith('#'): + flush() + else: + state['after'].append(line) + continue + + if state['file'] and state['section']: + state[state['section']].append(line) + elif line.strip() and not line.startswith('#'): + pass + + flush() + return patches + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tree', help='decompiled source root') + parser.add_argument('patches', help='patch file') + args = parser.parse_args() + + patches = parse(args.patches) + applied = 0 + failed = [] + total = 0 + + for rel, entries in sorted(patches.items()): + target = os.path.join(args.tree, rel.replace('/', os.sep)) + if not os.path.isfile(target): + failed.append('%s: no such file' % rel) + total += len(entries) + continue + with open(target, encoding='utf-8', errors='replace') as handle: + text = handle.read() + for index, (before, after, whole) in enumerate(entries): + total += 1 + label = rel if len(entries) == 1 else '%s [%d]' % (rel, index + 1) + if whole: + with open(target, 'w', encoding='utf-8', newline='\n') as handle: + handle.write(after) + applied += 1 + continue + if before in text: + with open(target, 'w', encoding='utf-8', newline='\n') as handle: + handle.write(text.replace(before, after)) + applied += 1 + # Re-read: the next patch for this file must see what this one + # just wrote. Without this each patch writes from the snapshot + # taken before the loop, and only the last one survives. + with open(target, encoding='utf-8', errors='replace') as handle: + text = handle.read() + elif after.strip() and after.strip() in text: + # already applied, which is the normal case on a second run + applied += 1 + else: + failed.append('%s: pattern not found' % label) + + # Safety net: after writing, no BEFORE text may still be present. This is + # what catches a patch that reported success while doing nothing, which is + # the failure mode that hides longest. + stale = [] + for rel, entries in sorted(patches.items()): + target = os.path.join(args.tree, rel.replace('/', os.sep)) + if not os.path.isfile(target): + continue + with open(target, encoding='utf-8', errors='replace') as handle: + text = handle.read() + for index, (before, after, whole) in enumerate(entries): + if whole or not before: + continue + if before in text and after.strip() and after.strip() not in text: + stale.append('%s [%d]' % (rel, index + 1)) + + print('patches applied: %d of %d' % (applied, total)) + for line in failed: + print(' FAILED %s' % line) + for line in stale: + print(' NOT WRITTEN %s' % line) + return 1 if (failed or stale) else 0 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/rsx-client/tools/fetch_missing.py b/rsx-client/tools/fetch_missing.py new file mode 100644 index 0000000..014232d --- /dev/null +++ b/rsx-client/tools/fetch_missing.py @@ -0,0 +1,70 @@ +"""Fetch library files the local meta folder never downloaded. + +The AstralRinth install has the 1.12.2 java libraries but not the +natives-windows classifiers, and the client cannot open a window without +lwjgl.dll. Rather than write into another application's cache, missing files go +into our own mirror of the same layout, which stage_run.py searches first. + + python fetch_missing.py +""" + +import json +import os +import sys +import urllib.request + + +def wanted(manifest): + """Every artifact and natives-windows path the manifest declares.""" + out = [] + for entry in manifest['libraries']: + downloads = entry.get('downloads') or {} + artifact = (downloads.get('artifact') or {}).get('path') + if artifact: + out.append(artifact) + classifiers = downloads.get('classifiers') or {} + for key, value in classifiers.items(): + if key.startswith('natives-windows') and value and value.get('path'): + out.append(value['path']) + return sorted(set(out)) + + +def main(): + dest_root, meta = sys.argv[1], sys.argv[2] + manifest_path = os.path.join(meta, 'versions', '1.12.2', '1.12.2.json') + with open(manifest_path, encoding='utf-8') as handle: + manifest = json.load(handle) + + meta_lib = os.path.join(meta, 'libraries') + base = 'https://libraries.minecraft.net/' + fetched, present, failed = [], 0, [] + + for rel in wanted(manifest): + if os.path.isfile(os.path.join(meta_lib, rel.replace('/', os.sep))): + present += 1 + continue + target = os.path.join(dest_root, rel.replace('/', os.sep)) + url = base + rel + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + with urllib.request.urlopen(url, timeout=60) as response: + blob = response.read() + with open(target, 'wb') as handle: + handle.write(blob) + fetched.append('%s (%d bytes)' % (os.path.basename(rel), len(blob))) + except Exception as error: # noqa: BLE001 + failed.append('%s -> %s' % (rel, error)) + + print('already present: %d' % present) + print('fetched: %d' % len(fetched)) + for line in fetched: + print(' %s' % line) + if failed: + print('failed: %d' % len(failed)) + for line in failed: + print(' %s' % line) + return 1 if failed else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) \ No newline at end of file diff --git a/rsx-client/tools/find_callers.py b/rsx-client/tools/find_callers.py new file mode 100644 index 0000000..d4befde --- /dev/null +++ b/rsx-client/tools/find_callers.py @@ -0,0 +1,57 @@ +"""Find every class whose constant pool references a named method. + +Used to answer "who actually calls this" across a whole jar, where grepping +source would only show the decompiled view and javap would need one command per +class. + + python find_callers.py +""" + +import os +import struct +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from struct_diff import Reader, read_constant_pool, CONSTANT_UTF8, SKIP_TWO_SLOT + + +def method_names(path): + with open(path, 'rb') as handle: + data = handle.read() + reader = Reader(data) + if reader.u4() != 0xCAFEBABE: + return None + reader.u2() + reader.u2() + pool = read_constant_pool(reader) + names = [] + for index, entry in enumerate(pool): + if isinstance(entry, str): + names.append(entry) + return names + + +def main(): + root, needle = sys.argv[1], sys.argv[2] + hits = [] + for base, _dirs, files in os.walk(root): + for name in files: + if not name.endswith('.class'): + continue + full = os.path.join(base, name) + key = os.path.relpath(full, root).replace(os.sep, '/') + try: + names = method_names(full) + except Exception: + continue + if names is None: + continue + if any(n == needle or n.startswith(needle) for n in names): + hits.append(key) + print('%d class(es) reference %s' % (len(hits), needle)) + for key in hits: + print(' %s' % key) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/rsx-client/tools/find_remove_index_bugs.py b/rsx-client/tools/find_remove_index_bugs.py new file mode 100644 index 0000000..48a3f50 --- /dev/null +++ b/rsx-client/tools/find_remove_index_bugs.py @@ -0,0 +1,59 @@ +"""Find List/Set.remove(int) misdecompilations. + +FernFlower sometimes resolves List.remove(Object) to remove(int index) when the +element type is Integer. The bytecode always boxes with Integer.valueOf first, +so a source-level remove() on an Integer collection without that box is a bug: +it deletes by position, and throws IndexOutOfBounds on an empty list. + + python find_remove_index_bugs.py +""" + +import io +import os +import re +import sys + + +FIELD = re.compile(r'\b(?:private|public|protected)?\s*(?:final\s+)?' + r'((?:java\.util\.)?(?:List|Set|Collection|ArrayList|LinkedList|Queue|Deque))\s*' + r'<\s*(?:java\.lang\.)?Integer\s*>\s+(\w+)\s*[;=]') +REMOVE = re.compile(r'\b(\w+)\.remove\(([^()]*)\)') + + +def main(): + root = sys.argv[1] + hits = [] + for base, _dirs, files in os.walk(root): + for name in files: + if not name.endswith('.java'): + continue + path = os.path.join(base, name) + with io.open(path, encoding='utf-8') as handle: + text = handle.read() + fields = {} + for match in FIELD.finditer(text): + fields[match.group(2)] = match.group(1) + if not fields: + continue + for number, line in enumerate(text.split('\n'), 1): + for match in REMOVE.finditer(line): + receiver, argument = match.group(1), match.group(2).strip() + if receiver not in fields: + continue + if 'valueOf' in argument or 'Integer' in argument: + continue + hits.append((os.path.relpath(path, root).replace(os.sep, '/'), + number, receiver, argument, line.strip())) + + if not hits: + print('no List.remove(int) candidates found') + return 0 + print('%d candidate(s):' % len(hits)) + for path, number, receiver, argument, line in hits: + print(' %s:%d %s.remove(%s)' % (path, number, receiver, argument)) + print(' %s' % line) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) \ No newline at end of file diff --git a/rsx-client/tools/find_self_comparisons.py b/rsx-client/tools/find_self_comparisons.py new file mode 100644 index 0000000..cc5a0bc --- /dev/null +++ b/rsx-client/tools/find_self_comparisons.py @@ -0,0 +1,47 @@ +"""Find comparisons of a value against itself. + +FernFlower sometimes merges two distinct locals into one name, producing +expressions like d1 != d1 that are always false. The compiler accepts them, so +they survive a clean build and silently disable the code around them. + + python find_self_comparisons.py +""" + +import io +import os +import re +import sys + +# a.b() != a.b() and simple locals: word != word, word == word +COMPARISON = re.compile(r'(? +""" + +import os +import re +import sys + +RULES = [ + ( + 'super-cast-generic-render', + # RenderLiving.doRender takes T. The decompiler inserted a cast to the + # supertype, which turns the argument into something that is no longer a T. + # Passing the variable as declared is always valid here: the parameter is + # the subclass type this renderer was parameterised with. + re.compile(r'(super\.\w+\()\((?:Entity|EntityLiving|EntityLivingBase|EntityMob)\)(\s+\w+,)'), + r'\1\2', + ), +] + + +def main(): + if len(sys.argv) != 2: + print('usage: fix_decompiler_rules.py ') + return 2 + tree = sys.argv[1] + touched = {} + files = set() + + for root, _dirs, names in os.walk(tree): + for name in names: + if not name.endswith('.java'): + continue + path = os.path.join(root, name) + with open(path, encoding='utf-8', errors='replace') as handle: + text = handle.read() + original = text + for label, pattern, replacement in RULES: + new_text = pattern.sub(replacement, text) + if new_text != text: + touched[label] = touched.get(label, 0) + \ + len(pattern.findall(text)) + text = new_text + if text != original: + files.add(path) + with open(path, 'w', encoding='utf-8', newline='\n') as handle: + handle.write(text) + + if not touched: + print('decompiler rules: nothing to change') + return 0 + for label, count in sorted(touched.items()): + print(' %s: %d' % (label, count)) + print('files rewritten: %d' % len(files)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) \ No newline at end of file diff --git a/rsx-client/tools/fix_generic_casts.py b/rsx-client/tools/fix_generic_casts.py new file mode 100644 index 0000000..e6d48e2 --- /dev/null +++ b/rsx-client/tools/fix_generic_casts.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Insert the missing cast where a generic method loses its type parameter. + +Almost every remaining error in the fork has one shape: a value whose static +type came out erased is used where the enclosing method's type parameter is +expected. + + Object cannot be converted to T + Comparable cannot be converted to T + Class cannot be converted to Class + +The cast that is already there was written to the erased supertype, so the fix +is to retype it. This is only safe when the line contains exactly one candidate +cast, which is the condition checked here; anything ambiguous is reported and +left alone. + + python fix_generic_casts.py [--apply] +""" + +import argparse +import os +import re + +ERROR = re.compile(r'^(?P[A-Za-z]:.*?\.java):(?P\d+): error: (?P.*)$') +# javac prefixes most of these with "incompatible types: ", some not at all. +PREFIX = re.compile(r'^(?:incompatible types:\s*)?') +MISMATCH = re.compile(r'^(?P[\w.]+(?:<.*?>)?(?:\[\])?) cannot be converted to (?P\w+)$') +GENERIC_MISMATCH = re.compile(r'^Class cannot be converted to Class<(?P\w+)>$') + +# A method head, used to confirm that a return really has the type we are about +# to cast it to. The type is whatever follows the last '>' of the pre-name +# segment, so a declaration like "public T getTile()" +# reports T and "private static > String getName()" reports +# String. Reading it any other way gets generics-bearing declarations wrong. +SIGNATURE = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|' + r'synchronized|native|default|strictfp)\s+)*' + r'(?P
[\w$.<>,\[\] ]+?)\s+(?P\w+)\s*\([^;]*\)'
+                       r'\s*(?:throws\s+[\w., ]+)?\s*\{\s*$')
+
+# if / while / for and friends fit the shape above but are not method heads.
+KEYWORDS = frozenset((
+    'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'try', 'catch',
+    'finally', 'return', 'new', 'synchronized', 'assert', 'throw', 'super',
+    'this', 'null', 'true', 'false',
+))
+
+
+def enclosing_return_type(lines, index):
+    # The window is generous on purpose: a method body can run long before the
+    # value that needs the cast, and missing the declaration would skip a fix
+    # that is perfectly safe.
+    for back in range(index, max(-1, index - 60), -1):
+        sig = SIGNATURE.match(lines[back])
+        if sig and sig.group('name') not in KEYWORDS:
+            return sig.group('pre').split('>')[-1].strip()
+    return None
+
+# Bare single letters and short names are the type variables javac prints.
+TYPE_VAR = re.compile(r'^[A-Z]\w{0,2}$')
+
+
+def candidates(line, from_type):
+    """Every `(Type)` cast on the line matching the type javac complained about."""
+    plain = from_type.split('<')[0]
+    found = []
+    for m in re.finditer(r'\(([^()]*)\)', line):
+        text = m.group(1).strip()
+        if text == plain or text == from_type:
+            found.append(m)
+    return found
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('errors')
+    parser.add_argument('--apply', action='store_true')
+    args = parser.parse_args()
+
+    per_file = {}
+    skipped = []
+    current = None
+    with open(args.errors, encoding='utf-8', errors='replace') as handle:
+        for raw in handle:
+            m = ERROR.match(raw.rstrip('\n').rstrip('\r'))
+            if not m:
+                continue
+            current = (m.group('file'), int(m.group('line')), m.group('msg'))
+            msg = PREFIX.sub('', current[2])
+
+            generic = GENERIC_MISMATCH.match(msg)
+            if generic:
+                to = generic.group('to')
+                per_file.setdefault(current[0], []).append(
+                    (current[1], to, None, msg))
+                continue
+
+            mm = MISMATCH.match(msg)
+            if not mm:
+                continue
+            to = mm.group('to')
+            if not TYPE_VAR.match(to):
+                continue
+            per_file.setdefault(current[0], []).append(
+                (current[1], to, mm.group('from'), msg))
+
+    fixed = 0
+    files_changed = 0
+
+    for path, items in sorted(per_file.items()):
+        if not os.path.isfile(path):
+            continue
+        with open(path, encoding='utf-8', errors='replace') as handle:
+            lines = handle.read().split('\n')
+
+        touched = False
+        for item in items:
+            lineno, to, from_type, message = item
+            if lineno - 1 >= len(lines):
+                continue
+            line = lines[lineno - 1]
+
+            if from_type is None:
+                # Class -> Class: the capture comes out of a getter
+                # such as getRawType(), so the cast belongs on the right hand side.
+                spots = [m for m in re.finditer(r'=\s*([^=].*?);\s*$', line)]
+                if len(spots) != 1:
+                    skipped.append('%s:%d %s (assignments=%d)'
+                                   % (os.path.basename(path), lineno, message, len(spots)))
+                    continue
+                m = spots[0]
+                lines[lineno - 1] = (line[:m.start(1)]
+                                     + '(Class<%s>) ' % to
+                                     + line[m.start(1):])
+                touched = True
+                fixed += 1
+                continue
+
+            spots = candidates(line, from_type)
+            replacement = '(%s)' % to
+
+            if len(spots) == 1:
+                m = spots[0]
+                lines[lineno - 1] = line[:m.start()] + replacement + line[m.end():]
+                touched = True
+                fixed += 1
+                continue
+
+            # No cast on the line at all: the value was simply used raw. Insert
+            # one immediately after the assignment or the return, which is where
+            # the target type is known. A return is only safe when the method
+            # actually returns that type variable: casting a String to T would
+            # introduce a new error rather than fix the old one.
+            anchor = re.search(r'(?<=return )|(?<==\s)(?!=)', line)
+            if anchor is None:
+                skipped.append('%s:%d %s (no anchor)'
+                               % (os.path.basename(path), lineno, message))
+                continue
+            cut = anchor.end()
+            if line[:cut].rstrip().endswith('return'):
+                enclosing = enclosing_return_type(lines, lineno - 1)
+                if enclosing != to:
+                    skipped.append('%s:%d %s (method returns %s, not %s)'
+                                   % (os.path.basename(path), lineno, message,
+                                      enclosing or '?', to))
+                    continue
+            lines[lineno - 1] = line[:cut] + replacement + ' ' + line[cut:]
+            touched = True
+            fixed += 1
+
+        if touched:
+            files_changed += 1
+            if args.apply:
+                with open(path, 'w', encoding='utf-8', newline='\n') as handle:
+                    handle.write('\n'.join(lines))
+
+    print('casts rewritten: %d in %d files' % (fixed, files_changed))
+    if skipped:
+        print('left alone: %d' % len(skipped))
+        for line in skipped:
+            print('  %s' % line)
+    if not args.apply:
+        print('dry run, pass --apply to write')
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
\ No newline at end of file
diff --git a/rsx-client/tools/fix_stale_locals.py b/rsx-client/tools/fix_stale_locals.py
new file mode 100644
index 0000000..bbe5a2f
--- /dev/null
+++ b/rsx-client/tools/fix_stale_locals.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Repair FernFlower's stale local variable references, scoped to the method.
+
+The decompiler renames conflicting locals only sometimes: a declaration ends up
+as `variantlist` while its uses stay `variantlist1`, and javac reports
+"cannot find symbol ... variable variantlist1".
+
+Renaming is confined to the enclosing method. That matters because the suffixed
+name is often a real, correctly declared variable in a *different* method of the
+same class, and a whole-file rename would corrupt it.
+
+The method is found by its declaration line and the closing brace at the same
+indentation, which is reliable for well-formed decompiled output.
+
+    python fix_stale_locals.py  [--apply]
+"""
+
+import argparse
+import collections
+import os
+import re
+
+ERROR = re.compile(r'^(?P[A-Za-z]:.*?\.java):(?P\d+): error: (?P.*)$')
+SYMBOL = re.compile(r'^ {2}symbol:\s*variable (?P\w+)\s*$')
+DECL = re.compile(r'^[ \t]*(?:final\s+)?[A-Za-z_$][\w$.<>,\[\] ]*\s+(?P[A-Za-z_$]\w*)\s*(?==|;|,|\))')
+METHOD = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*'
+                    r'[\w$.<>,\[\] ]+\s+\w+\s*\([^;]*\)\s*(?:throws\s+[\w., ]+)?\s*\{\s*$')
+
+
+def method_regions(lines):
+    """Returns [(start_index, end_index_exclusive, indent)] for each method."""
+    regions = []
+    for i, line in enumerate(lines):
+        if not METHOD.match(line):
+            continue
+        indent = len(line) - len(line.lstrip())
+        close = ' ' * indent + '}'
+        for j in range(i + 1, len(lines)):
+            if lines[j].rstrip() == close:
+                regions.append((i, j + 1, indent))
+                break
+    return regions
+
+
+def region_of(regions, index):
+    for start, end, _ in regions:
+        if start <= index < end:
+            return start, end
+    return None
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('errors')
+    parser.add_argument('--apply', action='store_true')
+    args = parser.parse_args()
+
+    missing = collections.defaultdict(set)
+    current = None
+    with open(args.errors, encoding='utf-8', errors='replace') as handle:
+        for raw in handle:
+            line = raw.rstrip('\n').rstrip('\r')
+            m = ERROR.match(line)
+            if m:
+                current = m.group('file')
+                continue
+            s = SYMBOL.match(line)
+            if s and current:
+                missing[current].add(s.group('name'))
+
+    files_changed = 0
+    renames = 0
+    skipped = []
+
+    for path in sorted(missing):
+        if not os.path.isfile(path):
+            continue
+        with open(path, encoding='utf-8', errors='replace') as handle:
+            text = handle.read()
+        lines = text.split('\n')
+        regions = method_regions(lines)
+
+        # for each stale name, which regions hold the clean declaration
+        edits = collections.defaultdict(list)  # old name -> list of (start, end)
+        for name in sorted(missing[path]):
+            pattern = re.compile(r'\b%s\b' % re.escape(name))
+            longer = re.compile(r'\b%s\d+\b' % re.escape(name))
+            shorter = name.rstrip('0123456789')
+            shorter_pattern = re.compile(r'\b%s\b' % re.escape(shorter)) if shorter != name else None
+
+            hits = 0
+            for start, end, _indent in regions:
+                chunk = '\n'.join(lines[start:end])
+                if not pattern.search(chunk):
+                    continue
+                # Either direction may be the stale one: sometimes the use kept
+                # the short name while the declaration gained a suffix.
+                target = None
+                if shorter_pattern is not None and shorter_pattern.search(chunk):
+                    target = shorter
+                else:
+                    seen = {m.group(0) for m in longer.finditer(chunk)}
+                    if len(seen) == 1:
+                        target = seen.pop()
+                if target is None:
+                    continue
+                edits[name].append((start, end, target))
+                hits += 1
+
+            if not hits:
+                skipped.append('%s: nothing in scope matches %s'
+                               % (os.path.basename(path), name))
+            else:
+                renames += hits
+
+        if not edits:
+            continue
+
+        # rebuild from the last region so earlier edits keep their positions
+        replacements = []
+        for name, spans in edits.items():
+            for start, end, target in spans:
+                chunk = '\n'.join(lines[start:end])
+                chunk = re.sub(r'\b%s\b' % re.escape(name), target, chunk)
+                replacements.append((start, end, chunk))
+
+        replacements.sort(key=lambda t: t[0], reverse=True)
+        for start, end, chunk in replacements:
+            lines[start:end] = chunk.split('\n')
+
+        files_changed += 1
+        if args.apply:
+            with open(path, 'w', encoding='utf-8', newline='\n') as handle:
+                handle.write('\n'.join(lines))
+
+    print('methods fixed: %d across %d files' % (renames, files_changed))
+    if skipped:
+        print('unresolved %d:' % len(skipped))
+        for line in sorted(set(skipped)):
+            print('  %s' % line)
+    if not args.apply:
+        print('dry run, pass --apply to write')
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
\ No newline at end of file
diff --git a/rsx-client/tools/merge_srg_mcp.py b/rsx-client/tools/merge_srg_mcp.py
new file mode 100644
index 0000000..019d46b
--- /dev/null
+++ b/rsx-client/tools/merge_srg_mcp.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""Merge a vanilla SRG with the MCP CSVs into a single obf -> MCP SRG.
+
+Why this exists
+---------------
+The vanilla mapping for 1.12.2 arrives as `joined.srg`, whose right hand side is
+in SRG names: classes already carry their MCP names, but fields and methods are
+still `field_96303_A` / `func_175744_a`. The MCP CSVs are the table that turns
+those into `fancyStyling` / `isFancyStyling`.
+
+SpecialSource can then apply the merged file in one pass, which avoids two
+things that a tiny v2 pipeline would require: reading every class file to learn
+member descriptors, and chaining two remappers.
+
+    python merge_srg_mcp.py joined.srg mcp-stable out.srg
+
+Any member missing from the CSVs keeps its SRG name. That is deliberate and
+matches how a normal development jar looks: mapped where MCP knows the name,
+`field_XXXX_` where it does not.
+"""
+
+import argparse
+import csv
+import sys
+
+
+def read_csv(path):
+    """searge name -> MCP name."""
+    table = {}
+    try:
+        with open(path, newline='', encoding='utf-8') as handle:
+            reader = csv.reader(handle)
+            header = next(reader, None)
+            if header is None:
+                return table
+            # the first column is the SRG name, the second the MCP name
+            for row in reader:
+                if len(row) < 2:
+                    continue
+                searge = row[0].strip()
+                named = row[1].strip()
+                if searge and named:
+                    table[searge] = named
+    except OSError as exc:
+        print('cannot read %s: %s' % (path, exc), file=sys.stderr)
+    return table
+
+
+def merge_member_name(token, table):
+    """`owner/srgName` -> `owner/mcpName`, keeping the owner untouched."""
+    if '/' not in token:
+        return token
+    owner, _, name = token.rpartition('/')
+    return owner + '/' + table.get(name, name)
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('srg', help='vanilla joined.srg')
+    parser.add_argument('mcp_dir', help='directory holding fields.csv and methods.csv')
+    parser.add_argument('out', help='merged SRG to write')
+    args = parser.parse_args()
+
+    fields = read_csv(args.mcp_dir + '/fields.csv')
+    methods = read_csv(args.mcp_dir + '/methods.csv')
+    print('csv: %d fields, %d methods' % (len(fields), len(methods)))
+
+    stats = {'CL': 0, 'FD': 0, 'MD': 0, 'PK': 0, 'renamed_f': 0, 'renamed_m': 0}
+    skipped = 0
+
+    with open(args.srg, 'r', encoding='utf-8', errors='replace') as src, \
+            open(args.out, 'w', encoding='utf-8', newline='\n') as dst:
+
+        for line in src:
+            line = line.rstrip('\r\n')
+            if not line.strip():
+                continue
+
+            if line.startswith('CL: '):
+                # class names in the SRG already are the MCP names
+                stats['CL'] += 1
+                dst.write(line + '\n')
+                continue
+
+            if line.startswith('FD: '):
+                parts = line.split(' ')
+                if len(parts) < 3:
+                    skipped += 1
+                    continue
+                before = parts[2].rpartition('/')[2]
+                after = merge_member_name(parts[2], fields)
+                if after.rpartition('/')[2] != before:
+                    stats['renamed_f'] += 1
+                stats['FD'] += 1
+                dst.write('FD: %s %s\n' % (parts[1], after))
+                continue
+
+            if line.startswith('MD: '):
+                # MD: /  / ;
+                #
+                # The trailing semicolon belongs to the descriptor and is absent
+                # for primitive and void returns: ()I, ()Z, ()V carry none, while
+                # ()Ljava/lang/String; does. Appending one unconditionally turns
+                # ()I into the invalid ()I; and the remapper rejects it with
+                # "Unrecognized type in method descriptor: ;". Keep it verbatim.
+                trail = ';' if line.rstrip().endswith(';') else ''
+                parts = line.rstrip().rstrip(';').split(' ')
+                if len(parts) < 5:
+                    skipped += 1
+                    continue
+                before = parts[3].rpartition('/')[2]
+                after = merge_member_name(parts[3], methods)
+                if after.rpartition('/')[2] != before:
+                    stats['renamed_m'] += 1
+                stats['MD'] += 1
+                dst.write('MD: %s %s %s %s%s\n'
+                          % (parts[1], parts[2], after, parts[4], trail))
+                continue
+
+            if line.startswith('PK: '):
+                # Package lines exist for MCP's source-jar layout: `PK: . net/minecraft/src`
+                # moves the whole root package under net/minecraft/src. The CL lines
+                # already place every class at its final path, so applying this on top
+                # would double-prefix them, and SpecialSource's parser rejects the
+                # combination. Not needed for remapping a client jar.
+                stats['PK'] += 1
+                continue
+
+            skipped += 1
+
+    print('wrote %s' % args.out)
+    print('  CL=%(CL)d FD=%(FD)d MD=%(MD)d PK=%(PK)d' % stats)
+    print('  renamed %d fields, %d methods' % (stats['renamed_f'], stats['renamed_m']))
+    if skipped:
+        print('  skipped %d unrecognised lines' % skipped)
+
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/rsx-client/tools/show_errors.py b/rsx-client/tools/show_errors.py
new file mode 100644
index 0000000..235a910
--- /dev/null
+++ b/rsx-client/tools/show_errors.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Show compile errors next to the source line that caused them.
+
+javac prints file:line: error: message, which is not enough to work from: the
+interesting part is usually the statement, and hunting for it in a 3000 line
+decompiled file by hand is what turns a 160 error job into an afternoon.
+
+    python show_errors.py  [ ...] [--top N] [--file NAME]
+"""
+
+import argparse
+import collections
+import os
+import re
+
+LINE = re.compile(r'^(?P[A-Za-z]:.*?\.java):(?P\d+): error: (?P.*)$')
+DETAIL = re.compile(r'^ {2}(?Psymbol|location|where|required|found|reason):\s*(?P.*)$')
+
+
+def load(paths):
+    """Returns [(file, line, message, [detail lines])].
+
+    javac puts the useful part after the message: which symbol was missing, and
+    which types were expected. Those continuation lines are indented and carry
+    no filename, so they are attached to the error above them.
+    """
+    found = []
+    for path in paths:
+        pending = None
+        with open(path, encoding='utf-8', errors='replace') as handle:
+            for raw in handle:
+                line = raw.rstrip('\n').rstrip('\r')
+                m = LINE.match(line)
+                if m:
+                    if pending:
+                        found.append(pending)
+                    pending = (m.group('file'), int(m.group('line')),
+                               m.group('msg'), [])
+                    continue
+                if pending is not None:
+                    # The quoted source line and its caret sit between the error
+                    # and the symbol block, so anything that is not a detail line
+                    # is skipped rather than treated as the end of the error.
+                    d = DETAIL.match(line)
+                    if d:
+                        text = d.group('rest').strip()
+                        pending[3].append('%s: %s' % (d.group('key'), text)
+                                         if text else d.group('key'))
+        if pending:
+            found.append(pending)
+    return found
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('errors', nargs='+')
+    parser.add_argument('--top', type=int, default=0, help='only the N worst files')
+    parser.add_argument('--file', help='show only errors in this file (substring match)')
+    parser.add_argument('--context', type=int, default=0, help='lines around the error')
+    args = parser.parse_args()
+
+    found = load(args.errors)
+    if not found:
+        print('no errors')
+        return 0
+
+    counts = collections.Counter(f for f, _, _, _ in found)
+    if args.file:
+        targets = [f for f in counts if args.file in f]
+    elif args.top:
+        targets = [f for f, _ in counts.most_common(args.top)]
+    else:
+        targets = sorted(counts)
+
+    shown = 0
+    for path in targets:
+        rows = [(ln, msg, det) for f, ln, msg, det in found if f == path]
+        print('%s  (%d errors)' % (path, len(rows)))
+        cache = {}
+        for ln, msg, details in rows:
+            if path not in cache:
+                try:
+                    with open(path, encoding='utf-8', errors='replace') as handle:
+                        cache[path] = handle.readlines()
+                except OSError:
+                    cache[path] = []
+            body = cache[path]
+            print('  %d: %s' % (ln, msg))
+            for extra in details:
+                print('       %s' % extra)
+            lo = max(0, ln - 1 - args.context)
+            hi = min(len(body), ln + args.context)
+            for i in range(lo, hi):
+                mark = '>' if i == ln - 1 else ' '
+                print('     %s %5d | %s' % (mark, i + 1, body[i].rstrip()))
+            shown += 1
+            print()
+    print('---- %d error(s) across %d file(s)' % (len(found), len(counts)))
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
\ No newline at end of file
diff --git a/rsx-client/tools/stage_run.py b/rsx-client/tools/stage_run.py
new file mode 100644
index 0000000..44f1cf4
--- /dev/null
+++ b/rsx-client/tools/stage_run.py
@@ -0,0 +1,159 @@
+"""Stage a runnable 1.12.2 client from the compiled fork.
+
+Resolves the exact library set the launcher already downloaded for 1.12.2 rather
+than globbing the whole meta folder, which holds every version ever installed
+and would put four LWJGL generations on one classpath.
+
+    python stage_run.py   
+"""
+
+import json
+import os
+import sys
+import zipfile
+
+
+def allowed(entry):
+    """Honours the launcher rules block: natives are per-OS."""
+    rules = entry.get('rules')
+    if not rules:
+        return True
+    verdict = False
+    for rule in rules:
+        os_name = rule.get('os', {}).get('name')
+        if os_name is None or os_name == 'windows':
+            verdict = rule['action'] == 'allow'
+    return verdict
+
+
+def resolve(entry, lib_roots):
+    """Yields (path, kind) for one library coordinate, kind in class/native.
+
+    1.12.2 keeps natives in downloads.classifiers rather than in the maven
+    coordinate, so the artifact path and the natives-windows path are read
+    separately. Some entries are natives-only and carry no artifact at all,
+    which is why an absent artifact must not be treated as a fallback signal
+    on its own. Roots are searched in order, so a local mirror wins.
+    """
+    def locate(rel):
+        for root in lib_roots:
+            jar = os.path.join(root, rel.replace('/', os.sep))
+            if os.path.isfile(jar):
+                return jar
+        return None
+
+    downloads = entry.get('downloads') or {}
+    artifact = (downloads.get('artifact') or {}).get('path')
+    seen_artifact = False
+    if artifact:
+        jar = locate(artifact)
+        if jar:
+            seen_artifact = True
+            yield jar, 'class'
+
+    classifiers = downloads.get('classifiers') or {}
+    natives_only = False
+    for key in sorted(classifiers):
+        if not key.startswith('natives-windows'):
+            continue
+        path = (classifiers[key] or {}).get('path')
+        natives_only = True
+        if not path:
+            continue
+        jar = locate(path)
+        if jar:
+            yield jar, 'native'
+
+    if seen_artifact or natives_only or artifact:
+        return
+
+    # pre-1.13 style coordinates: group:artifact:version[:classifier]
+    parts = entry['name'].split(':')
+    classifier = parts[3] if len(parts) > 3 and parts[3] else ''
+    rel = '/'.join([parts[0].replace('.', '/'), parts[1], parts[2],
+                    '%s-%s%s.jar' % (parts[1], parts[2],
+                                     '-' + classifier if classifier else '')])
+    jar = locate(rel)
+    if jar:
+        yield jar, 'native' if classifier.startswith('natives') else 'class'
+
+
+def extract(jar, dest):
+    """Unpacks a natives jar, returning False when it is not a real archive.
+
+    Some downloads in the local meta folder are truncated stubs rather than
+    jars. Losing a natives jar only costs an optional feature, so a bad file is
+    reported and skipped instead of aborting the whole staging.
+    """
+    try:
+        with zipfile.ZipFile(jar) as archive:
+            archive.extractall(dest)
+        return True
+    except (zipfile.BadZipFile, OSError):
+        return False
+
+
+def main():
+    run, classes, meta = sys.argv[1], sys.argv[2], sys.argv[3]
+    version_json = os.path.join(meta, 'versions', '1.12.2', '1.12.2.json')
+    with open(version_json, encoding='utf-8') as handle:
+        manifest = json.load(handle)
+
+    for name in ('assets', 'config', 'saves', 'resourcepacks', 'logs',
+                 'screenshots', 'shaderpacks'):
+        os.makedirs(os.path.join(run, name), exist_ok=True)
+
+    lib_roots = [os.path.join(run, 'libraries'), os.path.join(meta, 'libraries')]
+    cp = [classes]
+    natives = []
+    missing = []
+    for entry in manifest['libraries']:
+        if not allowed(entry):
+            continue
+        found = list(resolve(entry, lib_roots))
+        if not found:
+            missing.append(entry['name'])
+            continue
+        for jar, kind in found:
+            (natives if kind == 'native' else cp).append(jar)
+
+    # the unpacked jar carries pack.png, log4j2.xml and the in-jar assets;
+    # the compiled classes go first so they shadow the original bytecode
+    unpacked = os.path.join(os.path.dirname(os.path.dirname(classes)), 'classes')
+    if os.path.isdir(unpacked):
+        cp.append(unpacked)
+
+    # the mod loader is part of the game now, not an agent beside it, so it
+    # belongs on the classpath of the same JVM that renders the client
+    loader_jar = os.path.join(os.path.dirname(os.path.dirname(
+        os.path.abspath(__file__))), 'dist', 'rsx-loader.jar')
+    if os.path.isfile(loader_jar):
+        cp.append(loader_jar)
+    else:
+        print('NOTE: %s missing, run scripts\\build.ps1' % loader_jar)
+
+    with open(os.path.join(run, 'classpath.txt'), 'w', encoding='utf-8') as handle:
+        handle.write(os.pathsep.join(cp))
+
+    native_dir = os.path.join(run, 'natives')
+    os.makedirs(native_dir, exist_ok=True)
+    skipped = []
+    for jar in natives:
+        if not extract(jar, native_dir):
+            skipped.append(os.path.basename(jar))
+
+    print('classpath entries: %d' % len(cp))
+    print('natives: %d jars -> %s' % (len(natives), native_dir))
+    if missing:
+        print('MISSING libraries (%d):' % len(missing))
+        for name in missing:
+            print('  %s' % name)
+    if skipped:
+        print('unusable native jars (%d), skipped:' % len(skipped))
+        for name in skipped:
+            print('  %s' % name)
+    return 1 if missing else 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
\ No newline at end of file
diff --git a/rsx-client/tools/struct_diff.py b/rsx-client/tools/struct_diff.py
new file mode 100644
index 0000000..3e6bdb0
--- /dev/null
+++ b/rsx-client/tools/struct_diff.py
@@ -0,0 +1,288 @@
+"""Structural comparison between the original bytecode and the rebuilt fork.
+
+javac proves a file compiles. It proves nothing about whether the compiler
+emitted the same class the decompiler read, and a misdecompiled class can be
+silently wrong: dropping an interface, losing a method, or emitting the wrong
+superclass all produce clean output.
+
+Both trees are on disk, so the honest check is to parse the class files
+directly and compare shape. This is a structural pass only, not a bytecode
+equivalence proof, but it catches the whole class of errors that compiling
+cannot: missing classes, lost interfaces, absent methods and fields.
+
+    python struct_diff.py   [--json out.json]
+"""
+
+import json
+import os
+import struct
+import sys
+
+CONSTANT_UTF8 = 1
+CONSTANT_CLASS = 7
+CONSTANT_STRING = 8
+CONSTANT_LONG = 5
+CONSTANT_DOUBLE = 6
+SKIP_TWO_SLOT = (CONSTANT_LONG, CONSTANT_DOUBLE)
+
+INTERFACE = 0x0200
+ABSTRACT = 0x0400
+SYNTHETIC = 0x1000
+
+
+class Reader:
+    """Minimal big-endian cursor over a class file."""
+
+    def __init__(self, data):
+        self.data = data
+        self.pos = 0
+
+    def u1(self):
+        value = self.data[self.pos]
+        self.pos += 1
+        return value
+
+    def u2(self):
+        value = struct.unpack_from('>H', self.data, self.pos)[0]
+        self.pos += 2
+        return value
+
+    def u4(self):
+        value = struct.unpack_from('>I', self.data, self.pos)[0]
+        self.pos += 4
+        return value
+
+    def take(self, size):
+        value = self.data[self.pos:self.pos + size]
+        self.pos += size
+        return value
+
+
+def read_constant_pool(reader):
+    """Returns a list indexed by pool index; entries are str or int."""
+    count = reader.u2()
+    pool = [None] * count
+    index = 1
+    while index < count:
+        tag = reader.u1()
+        if tag == CONSTANT_UTF8:
+            length = reader.u2()
+            pool[index] = reader.take(length).decode('utf-8', 'replace')
+        elif tag in (CONSTANT_CLASS, CONSTANT_STRING):
+            pool[index] = ('ref', reader.u2())
+        elif tag in (3, 4):                      # Integer, Float
+            pool[index] = reader.take(4)
+        elif tag in SKIP_TWO_SLOT:               # Long, Double
+            pool[index] = reader.take(8)
+        elif tag in (9, 10, 11, 12, 17, 18):     # refs, NameAndType, Dynamic
+            reader.take(4)
+            pool[index] = None
+        elif tag == 15:                          # MethodHandle
+            reader.take(3)
+            pool[index] = None
+        elif tag in (16, 19, 20):                # MethodType, Module, Package
+            reader.take(2)
+            pool[index] = None
+        else:
+            raise ValueError('unknown constant pool tag %d' % tag)
+        index += 2 if tag in SKIP_TWO_SLOT else 1
+    return pool
+
+
+def utf(pool, index):
+    entry = pool[index]
+    return entry if isinstance(entry, str) else '?'
+
+
+def class_name(pool, index):
+    entry = pool[index]
+    return utf(pool, entry[1]) if isinstance(entry, tuple) else '?'
+
+
+def skip_attributes(reader, pool):
+    count = reader.u2()
+    for _ in range(count):
+        reader.u2()                              # name index
+        length = reader.u4()
+        reader.take(length)
+
+
+def parse(path):
+    """Returns (name, superName, interfaces, methods, fields, referenced)."""
+    with open(path, 'rb') as handle:
+        data = handle.read()
+    reader = Reader(data)
+    if reader.u4() != 0xCAFEBABE:
+        raise ValueError('not a class file: %s' % path)
+    reader.u2()                                  # minor
+    reader.u2()                                  # major
+    pool = read_constant_pool(reader)
+    reader.u2()                                  # access flags
+    this_class = reader.u2()
+    super_class = reader.u2()
+    referenced = set()
+    for entry in pool:
+        if isinstance(entry, tuple) and entry[0] == 'ref':
+            target = pool[entry[1]]
+            if isinstance(target, str):
+                referenced.add(target)
+    interfaces = []
+    for _ in range(reader.u2()):
+        interfaces.append(class_name(pool, reader.u2()))
+    fields = set()
+    for _ in range(reader.u2()):
+        flags = reader.u2()
+        name = utf(pool, reader.u2())
+        descriptor = utf(pool, reader.u2())
+        if not flags & SYNTHETIC:
+            fields.add('%s %s' % (name, descriptor))
+        skip_attributes(reader, pool)
+    methods = set()
+    for _ in range(reader.u2()):
+        flags = reader.u2()
+        name = utf(pool, reader.u2())
+        descriptor = utf(pool, reader.u2())
+        if not flags & SYNTHETIC and name not in ('',):
+            methods.add('%s %s' % (name, descriptor))
+        skip_attributes(reader, pool)
+    return (class_name(pool, this_class), class_name(pool, super_class),
+            interfaces, methods, fields, referenced)
+
+
+def collect(root):
+    found = {}
+    for base, _dirs, files in os.walk(root):
+        for name in files:
+            if not name.endswith('.class'):
+                continue
+            full = os.path.join(base, name)
+            key = os.path.relpath(full, root).replace(os.sep, '/')
+            try:
+                found[key] = parse(full)
+            except (ValueError, struct.error, IndexError) as error:
+                found[key] = None
+                sys.stderr.write('unreadable %s: %s\n' % (key, error))
+    return found
+
+
+def describe(kind, items):
+    return sorted(items)
+
+
+def main():
+    original_root, rebuilt_root = sys.argv[1], sys.argv[2]
+    report_path = None
+    if '--json' in sys.argv:
+        report_path = sys.argv[sys.argv.index('--json') + 1]
+
+    original = collect(original_root)
+    rebuilt = collect(rebuilt_root)
+
+    only_original = sorted(set(original) - set(rebuilt))
+    only_rebuilt = sorted(set(rebuilt) - set(original))
+
+    lost_interfaces = []
+    lost_methods = []
+    lost_fields = []
+    changed_super = []
+    unreadable = []
+    referenced_by_rebuild = set()
+
+    for key in sorted(set(rebuilt)):
+        entry = rebuilt[key]
+        if entry is not None:
+            referenced_by_rebuild |= entry[5]
+
+    for key in sorted(set(original) & set(rebuilt)):
+        left, right = original[key], rebuilt[key]
+        if left is None or right is None:
+            unreadable.append(key)
+            continue
+        if set(left[2]) != set(right[2]):
+            lost_interfaces.append({
+                'class': key,
+                'missing': describe('i', set(left[2]) - set(right[2])),
+                'added': describe('i', set(right[2]) - set(left[2])),
+            })
+        if left[1] != right[1]:
+            changed_super.append({
+                'class': key,
+                'original': left[1],
+                'rebuilt': right[1],
+            })
+        gone_methods = left[3] - right[3]
+        if gone_methods:
+            lost_methods.append({'class': key, 'missing': sorted(gone_methods)})
+        gone_fields = left[4] - right[4]
+        if gone_fields:
+            lost_fields.append({'class': key, 'missing': sorted(gone_fields)})
+
+    # a class the rebuild never mentions cannot be reached at runtime, so the
+    # original bytecode on the classpath is never consulted for it
+    orphans = [k for k in only_original
+               if k[:-6] not in referenced_by_rebuild]
+    still_needed = [k for k in only_original
+                    if k[:-6] in referenced_by_rebuild]
+
+    report = {
+        'classes_original': len(original),
+        'classes_rebuilt': len(rebuilt),
+        'only_in_original': only_original,
+        'only_in_rebuilt': only_rebuilt,
+        'lost_interfaces': lost_interfaces,
+        'changed_super': changed_super,
+        'lost_methods': lost_methods,
+        'lost_fields': lost_fields,
+        'unreadable': unreadable,
+        'orphans': orphans,
+        'still_needed': still_needed,
+    }
+
+    print('classes: original %d, rebuilt %d' % (len(original), len(rebuilt)))
+    print('classes only in original : %d' % len(only_original))
+    print('classes only in rebuilt  : %d' % len(only_rebuilt))
+    print('lost interfaces          : %d' % len(lost_interfaces))
+    print('changed superclass       : %d' % len(changed_super))
+    print('lost methods             : %d' % sum(len(r['missing']) for r in lost_methods))
+    print('lost fields              : %d' % sum(len(r['missing']) for r in lost_fields))
+    print('missing but unreferenced : %d (dead weight, never loaded)' % len(orphans))
+    print('missing and still needed : %d (would fail without the fallback)' % len(still_needed))
+
+    if lost_interfaces:
+        print('\n--- LOST INTERFACES (fatal class of bug) ---')
+        for row in lost_interfaces:
+            print('  %s' % row['class'])
+            print('      - %s' % ', '.join(row['missing']))
+            if row['added']:
+                print('      + %s' % ', '.join(row['added']))
+    if changed_super:
+        print('\n--- CHANGED SUPERCLASS ---')
+        for row in changed_super:
+            print('  %-60s %s -> %s' % (row['class'], row['original'], row['rebuilt']))
+    if lost_methods:
+        print('\n--- LOST METHODS ---')
+        for row in lost_methods:
+            print('  %s' % row['class'])
+            for name in row['missing']:
+                print('      - %s' % name)
+    if lost_fields:
+        print('\n--- LOST FIELDS ---')
+        for row in lost_fields:
+            print('  %s' % row['class'])
+            for name in row['missing']:
+                print('      - %s' % name)
+    if only_original:
+        print('\n--- ONLY IN ORIGINAL (first 60) ---')
+        for key in only_original[:60]:
+            print('  %s' % key)
+        if len(only_original) > 60:
+            print('  ... and %d more' % (len(only_original) - 60))
+
+    if report_path:
+        with open(report_path, 'w', encoding='utf-8') as handle:
+            json.dump(report, handle, indent=2)
+        print('\nreport written to %s' % report_path)
+
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/rsx-mods/rsxm-demo/resources/META-INF/rsxmod.properties b/rsx-mods/rsxm-demo/resources/META-INF/rsxmod.properties
new file mode 100644
index 0000000..1b35f0e
--- /dev/null
+++ b/rsx-mods/rsxm-demo/resources/META-INF/rsxmod.properties
@@ -0,0 +1,7 @@
+id=rsxm-demo
+name=RustNotMC Demo
+version=1.1.0
+env=CLIENT
+entrypoint=net.rustnotmc.demo.DemoMod
+api=1
+access=hud,commands,files
\ No newline at end of file
diff --git a/rsx-mods/rsxm-demo/src/main/java/net/rustnotmc/demo/DemoMod.java b/rsx-mods/rsxm-demo/src/main/java/net/rustnotmc/demo/DemoMod.java
new file mode 100644
index 0000000..74bc88d
--- /dev/null
+++ b/rsx-mods/rsxm-demo/src/main/java/net/rustnotmc/demo/DemoMod.java
@@ -0,0 +1,142 @@
+package net.rustnotmc.demo;
+
+import java.io.File;
+
+import net.rustnotmc.api.Rsx;
+import net.rustnotmc.api.RustNotMcMod;
+import net.rustnotmc.api.client.ClientCommand;
+import net.rustnotmc.api.client.CommandArgs;
+import net.rustnotmc.api.client.CommandScope;
+import net.rustnotmc.api.client.ScreenKind;
+import net.rustnotmc.api.hud.Anchor;
+import net.rustnotmc.api.hud.HudLayout;
+import net.rustnotmc.api.hud.HudSurface;
+
+/**
+ * Shows that the client, the loader and the mod API are actually connected.
+ *
+ * Nothing here is cosmetic: every value comes from a jar in the mods folder,
+ * loaded by the loader, ticked by the client loop and drawn with the game's own
+ * font. The command registration also shows the scope rule, where a CLIENT
+ * command runs here and a SERVER one is deliberately left for the server.
+ */
+public final class DemoMod implements RustNotMcMod {
+
+    private static final int ACCENT = 0x63D2FF;
+    private static final int PADDING = 3;
+
+    private Rsx context;
+    private String lastCommand = "none yet";
+    private int commandHits;
+    private File directory;
+    private boolean sawWorld;
+
+    @Override
+    public void onConstruct(Rsx context) {
+        this.context = context;
+        // registering here rather than in onEnable keeps a disable/enable cycle safe
+        HudLayout.Slot slot = context.hud("demo", new Panel(), Anchor.TOP_RIGHT,
+                HudLayout.NORMAL, PADDING, PADDING);
+        if (slot == null) {
+            context.log().warn("no hud capability, no overlay");
+        }
+
+        // a client command: carried out here, never sent anywhere
+        context.command("rsxdemo", new ClientCommand() {
+            @Override
+            public void run(CommandArgs args) {
+                note("/rsxdemo handled locally: " + args.raw());
+            }
+        }, CommandScope.CLIENT);
+
+        // a server command: registered so the name is claimed, and deliberately
+        // not executed here, because the server decides what it does
+        context.command("rsxadmin", new ClientCommand() {
+            @Override
+            public void run(CommandArgs args) {
+                note("this handler must never run on the client");
+            }
+        }, CommandScope.SERVER);
+
+        this.directory = context.modDirectory();
+    }
+
+    @Override
+    public void onLoad() {
+        this.context.log().info("hello from inside the client");
+    }
+
+    @Override
+    public void onEnable() {
+        this.context.log().info("enabled as api v" + this.context.apiVersion());
+        this.context.log().info("screen is " + this.context.client().screen().kind());
+        if (this.directory != null) {
+            this.context.log().info("my directory is " + this.directory);
+        }
+    }
+
+    private void note(String message) {
+        this.lastCommand = message;
+        this.commandHits++;
+        this.context.log().info(message);
+    }
+
+    private String[] lines() {
+        ScreenKind kind = this.context.client().screen().kind();
+        if (kind.inWorld()) {
+            this.sawWorld = true;
+        }
+        return new String[]{
+                "RustNotMC " + this.context.modVersion() + "  (api v"
+                        + this.context.apiVersion() + ")",
+                kind.name().toLowerCase() + "  |  tick " + this.context.tick(),
+                this.context.client().frame() + " frames"
+                        + (this.context.hasClient() ? "" : "  [detached]"),
+                "/rsxdemo x" + (this.commandHits == 0
+                        ? "   |  /rsxadmin -> server"
+                        : "  (" + this.commandHits + ")"
+                        + "   |  /rsxadmin -> server"),
+                this.lastCommand,
+        };
+    }
+
+    /** Draw is a plain interface with three methods, not a lambda target. */
+    private final class Panel implements HudLayout.Draw {
+
+        @Override
+        public int width(HudSurface hud) {
+            int widest = 0;
+            String[] lines = lines();
+            for (int i = 0; i < lines.length; i++) {
+                widest = Math.max(widest, (int) hud.textWidth(lines[i]));
+            }
+            return widest;
+        }
+
+        @Override
+        public int height(HudSurface hud) {
+            return lines().length * (hud.textHeight() + 2) + 2;
+        }
+
+        @Override
+        public void draw(HudSurface hud, int x, int y, int width, int height) {
+            hud.rect(x, y, width, height, ACCENT, 0.16f);
+            String[] lines = lines();
+            int lineHeight = hud.textHeight() + 2;
+            for (int i = 0; i < lines.length; i++) {
+                int tint = i == 0 ? ACCENT : (i == lines.length - 1 ? 0xFFD98C : 0xE8E8E8);
+                hud.text(x + 2, y + 2 + i * lineHeight, lines[i], tint, 0.9f);
+            }
+        }
+    }
+
+    @Override
+    public void onDisable() {
+        this.context.log().info("disabled, saw a world: " + this.sawWorld);
+    }
+
+    @Override
+    public void onUnload() {
+        this.context.clearHud();
+    }
+}
\ No newline at end of file
diff --git a/rsx-plugin-api/LICENSE b/rsx-plugin-api/LICENSE
new file mode 100644
index 0000000..ea7e6d4
--- /dev/null
+++ b/rsx-plugin-api/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 RustNotMC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/rsx-protocol/LICENSE b/rsx-protocol/LICENSE
new file mode 100644
index 0000000..ea7e6d4
--- /dev/null
+++ b/rsx-protocol/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 RustNotMC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/rsx-server-core/LICENSE b/rsx-server-core/LICENSE
new file mode 100644
index 0000000..8ff7d67
--- /dev/null
+++ b/rsx-server-core/LICENSE
@@ -0,0 +1,9 @@
+RustNotMC - rsx-server-core
+Copyright (c) 2026 RustNotMC. All rights reserved.
+
+This repository is proprietary and is not published. No part of it may be
+copied, modified, redistributed or used to build a derivative work without
+written permission from the copyright holder.
+
+Third-party components vendored under third_party\ remain under their own
+licenses; see the notices in those directories.