From c31c6a873a1397b6c1b736de99ba69b9f493490d Mon Sep 17 00:00:00 2001 From: Shirona1337 Date: Sat, 23 May 2026 09:06:24 +0800 Subject: [PATCH] feat(dll): add hot-injection DLL path with embedded GUI loader Ship a self-contained OpenZenLoader.exe (OpenZen.dll embedded as RCDATA) that injects into a running Minecraft 1.20.1 Forge javaw.exe and brings up the Zen client without -javaagent or a mods/ entry. The existing Forge mod path stays intact. Native (native/): - DLL: JNI_GetCreatedJavaVMs + JVMTI late-attach via the JDK's instrument.dll Agent_OnAttach to obtain a real Instrumentation; URLClassLoader on the game loader loads a single bridge class. - Loader EXE: Win32 listview of javaw.exe/java.exe processes with window titles, CreateRemoteThread+LoadLibraryW injection, embedded DLL extracted to %TEMP%\OpenZenLoader at runtime. Java: - shit.zen.dll.GameLoaderBridge re-defines every jar class onto the Forge GameClassLoader (fixed-point retry for super-class deps) and extracts non-class resources to %TEMP%\openzen-resources-, exposed via the openzen.resources system property. - shit.zen.dll.DllBootstrap only runs Bootstrap.init + registerPatches + installPatchesAndRetransform; ZenClient construction stays on MinecraftPatch.onTick lazy-init. - shit.zen.asm.Bootstrap parses mapping.srg, detects SRG vs mojmap runtime via Minecraft.tick reflection, exposes remapMethod/remapField used at 5 PatchTransformer match points and from ReflectionUtil. - ReflectionUtil.resolveField walks the superclass chain trying SRG then mojmap on each level so e.g. activeEffects on LocalPlayer resolves on LivingEntity. - shit.zen.utils.misc.Assets unifies resource lookup with a fallback to openzen.resources; fonts, cloud assets and WebUI static files route through it. - PatchAgent.installPatchesAndRetransform is now idempotent so DllBootstrap and ZenClient.init can both call it safely. Build: - ./gradlew jar forge mod jar (unchanged) - ./gradlew dll single-file Loader EXE in build/dist - CMake auto-located via vswhere when VS 2022 ships it. - README documents both paths and required toolchain. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 + README.md | 68 ++++- build.gradle | 122 +++++++++ native/CMakeLists.txt | 31 +++ native/dll/CMakeLists.txt | 45 +++ native/dll/res/openzen.rc | 3 + native/dll/res/resource.h | 3 + native/dll/src/class_loader.cpp | 138 ++++++++++ native/dll/src/diagnostics.cpp | 69 +++++ native/dll/src/jar_extract.cpp | 57 ++++ native/dll/src/jvm_attach.cpp | 102 +++++++ native/dll/src/main.cpp | 107 ++++++++ native/dll/src/openzen.h | 48 ++++ native/loader/CMakeLists.txt | 51 ++++ native/loader/res/loader.rc | 3 + native/loader/res/resource.h | 3 + native/loader/src/embedded_dll.cpp | 39 +++ native/loader/src/injector.cpp | 88 ++++++ native/loader/src/loader.h | 33 +++ native/loader/src/main.cpp | 5 + native/loader/src/process_list.cpp | 69 +++++ native/loader/src/ui.cpp | 259 ++++++++++++++++++ native/loader/src/window_title.cpp | 43 +++ .../java/asm/patchify/loader/PatchAgent.java | 9 +- .../asm/patchify/loader/PatchTransformer.java | 27 +- src/main/java/shit/zen/ZenClient.java | 35 ++- src/main/java/shit/zen/asm/Bootstrap.java | Bin 0 -> 8624 bytes src/main/java/shit/zen/dll/DllBootstrap.java | 61 +++++ .../java/shit/zen/dll/GameLoaderBridge.java | 185 +++++++++++++ .../zen/network/webui/StaticFileHandler.java | 5 +- .../java/shit/zen/patch/MinecraftPatch.java | 4 + src/main/java/shit/zen/render/FontStore.java | 3 +- src/main/java/shit/zen/render/Fonts.java | 3 +- src/main/java/shit/zen/utils/misc/Assets.java | 45 +++ .../shit/zen/utils/misc/ReflectionUtil.java | 39 ++- 35 files changed, 1782 insertions(+), 24 deletions(-) create mode 100644 native/CMakeLists.txt create mode 100644 native/dll/CMakeLists.txt create mode 100644 native/dll/res/openzen.rc create mode 100644 native/dll/res/resource.h create mode 100644 native/dll/src/class_loader.cpp create mode 100644 native/dll/src/diagnostics.cpp create mode 100644 native/dll/src/jar_extract.cpp create mode 100644 native/dll/src/jvm_attach.cpp create mode 100644 native/dll/src/main.cpp create mode 100644 native/dll/src/openzen.h create mode 100644 native/loader/CMakeLists.txt create mode 100644 native/loader/res/loader.rc create mode 100644 native/loader/res/resource.h create mode 100644 native/loader/src/embedded_dll.cpp create mode 100644 native/loader/src/injector.cpp create mode 100644 native/loader/src/loader.h create mode 100644 native/loader/src/main.cpp create mode 100644 native/loader/src/process_list.cpp create mode 100644 native/loader/src/ui.cpp create mode 100644 native/loader/src/window_title.cpp create mode 100644 src/main/java/shit/zen/asm/Bootstrap.java create mode 100644 src/main/java/shit/zen/dll/DllBootstrap.java create mode 100644 src/main/java/shit/zen/dll/GameLoaderBridge.java create mode 100644 src/main/java/shit/zen/utils/misc/Assets.java diff --git a/.gitignore b/.gitignore index 12f8644..705cdad 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ run # Files from Forge MDK forge*changelog.txt + +# Native DLL build artifacts (CMake out-of-source build + staged jar) +native/build/ +native/zen.jar diff --git a/README.md b/README.md index b855a22..0d4b8c9 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,6 @@ ## 截图 ![Open Zen ClickGUI](img/screenshot.png) -## TODO - - 直接构建为热注入DLL。 - - 你来提出Issue,我来解决。 - ## 原始 Jar + Mapping [原始Jar](./mapping/zen-orignial.jar) [Mapping](./mapping/zen.mapping) @@ -50,6 +46,70 @@ ## 状态与注意事项 - 这是**尽力而为的反混淆结果**,部分符号是根据上下文重建的,可能与原作者的命名意图不一致。 +## 构建 + +OpenZen 支持两种交付形式:**Forge Mod (jar)** 和 **热注入器 (单文件 EXE,内嵌 DLL)**。Mod 路径只要 JDK,注入器路径还需要 MSVC 工具链。 + +### 共同前置 + +- **JDK 17**(推荐 Microsoft Build of OpenJDK / Temurin / Azul Zulu 任一)。 +- 必须设置 `JAVA_HOME` 环境变量指向该 JDK 安装目录(PowerShell 验证:`echo $env:JAVA_HOME`)。 +- 仓库根目录用 `gradlew.bat` 即可,**不需要**单独安装 Gradle。 + +首次执行会从 ForgeMaven 下载 1.20.1 + Forge 47.4.20 的 mappings 和依赖,耗时几分钟到十几分钟,取决于网络。 + +### 1. 构建为 Forge Mod (jar) + +零额外依赖。 + +```powershell +.\gradlew.bat jar +``` + +产物:`build/libs/hey-1.0-SNAPSHOT.jar`。把它丢进 `.minecraft/mods/`,按正常 Forge mod 启动即可。Mod ID 是 `hey`,Mod 列表里不会显示(`MinecraftPatch.onTick` 启动时会把自己从 `ModList` 摘掉)。 + +### 2. 构建为热注入器 (单文件 EXE) + +产出一个独立的 `OpenZenLoader.exe`,DLL 已经作为资源段嵌入 EXE 内部。用户分发只需要这一个文件,运行后 GUI 列出当前所有 `javaw.exe` 进程(含 Minecraft 窗口标题),选中后点 Inject 即可。 + +#### 额外前置 — 必须项 + +1. **Visual Studio 2022**(Community 版即可,免费)。安装时勾选: + - **"使用 C++ 的桌面开发"** 工作负载 + - 该工作负载的可选组件里勾上 **"适用于 Windows 的 C++ CMake 工具"**("C++ CMake tools for Windows") +2. **`JAVA_HOME` 必须指向 JDK 17**(不只是 JRE)。CMake 需要它定位 `/include/jni.h` 和 `/include/win32/jvmti.h`。 +3. **CMake**:VS 2022 自带,Gradle 会自动检测——也可以独立安装 [CMake](https://cmake.org/download/) 并加入 PATH。Gradle 的检测顺序: + 1. `PATH` 上的 `cmake.exe` + 2. 通过 `vswhere.exe` 找 VS 2022 自带的 CMake + 3. 常见独立安装位置 (`%ProgramFiles%\CMake\bin\cmake.exe` 等) + +#### 构建命令 + +```powershell +.\gradlew.bat dll +``` + +产物:`build/dist/OpenZenLoader.exe`(OpenZen.dll 已内嵌为 RCDATA 资源段)。 + +构建流程: +1. `jar` → 编译 Java 源码,执行 `reobfJar` 把字节码 mojmap→SRG +2. `stageNativeJar` → 拷 jar 到 `native/zen.jar`,等待 DLL 把它作为资源段嵌入 +3. `configureNative` → CMake configure (`-A x64`) +4. `buildNative` → 先 build `OpenZen.dll`,CMake 自定义命令把 DLL 拷到 loader 的 binary dir,rc.exe 再嵌入到 `OpenZenLoader.exe` +5. `packageDist` → 把 EXE 拷到 `build/dist/` + +#### 使用注入器 + +1. 用 HMCL / Forge 启动器正常启动 Minecraft 1.20.1 Forge(**不需要**任何特殊 JVM 参数)。 +2. 双击 `OpenZenLoader.exe`。 +3. 列表里找到对应的 `javaw.exe`(看 Window Title 列,应是 `Minecraft 1.20.1` 一类)。 +4. 点 Inject。 +5. 看到 `Injection ok. Watch %TEMP%\openzen.log for Java side.` 即成功。Minecraft 内右 Shift 触发 ClickGUI 验证。 + +诊断日志: +- Native 端:`%TEMP%\openzen.log` +- Java 端:Minecraft 自己的 `logs/latest.log`(搜索 `OpenZen-Bootstrap`、`GameLoaderBridge`、`PatchAgent`、`ZenBootstrap`) + ## 致谢 - 原始混淆客户端:**Zen**。 diff --git a/build.gradle b/build.gradle index db34b27..abb86a7 100644 --- a/build.gradle +++ b/build.gradle @@ -240,3 +240,125 @@ publishing { tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation } + +// ===== Native build (DLL injection path) ===== +// +// Produces: +// build/dist/OpenZen.dll - injectable DLL with embedded zen.jar +// build/dist/OpenZenLoader.exe - GUI injector +// +// Requires: +// - JAVA_HOME pointing at a JDK 17 install (for jni.h / jvmti.h) +// - CMake either on PATH, bundled with Visual Studio 2019/2022 +// ("C++ CMake tools for Windows" workload), or installed standalone. +// - MSVC build tools (Visual Studio 2019+). +// +// Usage: +// ./gradlew dll +// +def nativeDir = file('native') +def nativeBuildDir = file("$nativeDir/build") + +ext.findCmake = { + // 1. cmake already on PATH? + try { + def p = ['cmd', '/c', 'where', 'cmake'].execute() + def out = new StringBuilder() + p.consumeProcessOutput(out, new StringBuilder()) + p.waitForOrKill(2000) + if (p.exitValue() == 0) { + def first = out.toString().readLines().find { it?.trim() } + if (first) return first.trim() + } + } catch (Exception ignored) {} + + // 2. Visual Studio bundled cmake via vswhere. + def pf86 = System.getenv('ProgramFiles(x86)') ?: 'C:/Program Files (x86)' + def vswhere = "${pf86}/Microsoft Visual Studio/Installer/vswhere.exe" + if (file(vswhere).exists()) { + try { + def out = new StringBuilder() + def p = [vswhere, '-latest', '-products', '*', + '-requires', 'Microsoft.VisualStudio.Component.VC.CMake.Project', + '-property', 'installationPath'].execute() + p.consumeProcessOutput(out, new StringBuilder()) + p.waitForOrKill(5000) + def vsPath = out.toString().trim() + if (vsPath) { + def cmakeExe = "${vsPath}/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" + if (file(cmakeExe).exists()) return cmakeExe + } + } catch (Exception ignored) {} + } + + // 3. Standalone CMake installations. + def pf = System.getenv('ProgramFiles') ?: 'C:/Program Files' + def lad = System.getenv('LOCALAPPDATA') ?: '' + def candidates = [ + "${pf}/CMake/bin/cmake.exe", + "${pf86}/CMake/bin/cmake.exe", + "${lad}/Programs/CMake/bin/cmake.exe", + ] + for (path in candidates) { + if (path && file(path).exists()) return path + } + return null +} + +def cmakeMissingMessage = + 'CMake not found. Install one of:\n' + + ' - Standalone CMake: https://cmake.org/download/ (add to PATH)\n' + + ' - Visual Studio 2022 with "C++ CMake tools for Windows" workload\n' + + 'After installing, re-run ./gradlew dll' + +tasks.register('stageNativeJar', Copy) { + group = 'openzen' + description = 'Stage the freshly built mod jar at native/zen.jar so the DLL ' + + 'resource compiler can embed it.' + dependsOn 'jar' + from { tasks.jar.archiveFile } + into nativeDir + rename { 'zen.jar' } +} + +tasks.register('configureNative', Exec) { + group = 'openzen' + description = 'Run CMake configure on the native sub-projects (DLL + Loader).' + dependsOn 'stageNativeJar' + workingDir nativeDir + commandLine 'cmake', '-S', '.', '-B', 'build', '-A', 'x64' + doFirst { + def cmake = findCmake() + if (!cmake) throw new GradleException(cmakeMissingMessage) + logger.lifecycle("Using CMake: ${cmake}") + commandLine cmake, '-S', '.', '-B', 'build', '-A', 'x64' + } +} + +tasks.register('buildNative', Exec) { + group = 'openzen' + description = 'Compile OpenZen.dll and OpenZenLoader.exe with CMake.' + dependsOn 'configureNative' + workingDir nativeDir + commandLine 'cmake', '--build', 'build', '--config', 'Release' + doFirst { + def cmake = findCmake() + if (!cmake) throw new GradleException(cmakeMissingMessage) + commandLine cmake, '--build', 'build', '--config', 'Release' + } +} + +tasks.register('packageDist', Copy) { + group = 'openzen' + description = 'Collect the Loader EXE into build/dist for distribution. ' + + 'OpenZen.dll is embedded inside the EXE, so we only ship one file.' + dependsOn 'buildNative' + from "$nativeBuildDir/loader/Release/OpenZenLoader.exe" + into "$buildDir/dist" +} + +tasks.register('dll') { + group = 'openzen' + description = 'Build the injectable DLL and GUI Loader (final dist artifacts).' + dependsOn 'packageDist' +} diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 0000000..b54d790 --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.20) +project(OpenZenNative LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED ENV{JAVA_HOME}) + message(FATAL_ERROR "JAVA_HOME must be set so we can locate jni.h / jvmti.h") +endif() + +set(JDK_INCLUDE "$ENV{JAVA_HOME}/include") +include_directories(${JDK_INCLUDE} ${JDK_INCLUDE}/win32) + +if(NOT WIN32) + message(FATAL_ERROR "OpenZen native build only supports Windows") +endif() + +add_compile_definitions( + UNICODE + _UNICODE + WIN32_LEAN_AND_MEAN + NOMINMAX +) + +if(MSVC) + add_compile_options(/W3 /permissive- /utf-8 /EHsc) +endif() + +add_subdirectory(dll) +add_subdirectory(loader) diff --git a/native/dll/CMakeLists.txt b/native/dll/CMakeLists.txt new file mode 100644 index 0000000..72e04e5 --- /dev/null +++ b/native/dll/CMakeLists.txt @@ -0,0 +1,45 @@ +set(JAR_SOURCE "${CMAKE_SOURCE_DIR}/zen.jar") +set(JAR_STAGED "${CMAKE_CURRENT_BINARY_DIR}/zen.jar") + +if(NOT EXISTS ${JAR_SOURCE}) + message(WARNING + "zen.jar not found at ${JAR_SOURCE}; " + "did you run the Gradle 'stageNativeJar' task first? " + "The DLL will be built but the embedded jar resource will be empty.") + file(WRITE ${JAR_STAGED} "") +else() + configure_file(${JAR_SOURCE} ${JAR_STAGED} COPYONLY) +endif() + +add_library(OpenZen SHARED + src/main.cpp + src/jvm_attach.cpp + src/jar_extract.cpp + src/class_loader.cpp + src/diagnostics.cpp + res/openzen.rc +) + +target_include_directories(OpenZen PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/res +) + +# rc.exe needs to find zen.jar (staged into CMAKE_CURRENT_BINARY_DIR by the +# configure_file above) when assembling RCDATA from openzen.rc. +set_source_files_properties(res/openzen.rc PROPERTIES + COMPILE_FLAGS "/I\"${CMAKE_CURRENT_BINARY_DIR}\"" +) + +target_compile_definitions(OpenZen PRIVATE + OPENZEN_DLL_EXPORTS +) + +set_target_properties(OpenZen PROPERTIES + OUTPUT_NAME "OpenZen" + PREFIX "" +) + +# We do not link jvm.dll: jvm.dll is already loaded in the host process when the +# DLL is injected, and JNI_GetCreatedJavaVMs is provided by it. We only need +# the JNI headers at compile time. diff --git a/native/dll/res/openzen.rc b/native/dll/res/openzen.rc new file mode 100644 index 0000000..3db2f85 --- /dev/null +++ b/native/dll/res/openzen.rc @@ -0,0 +1,3 @@ +#include "resource.h" + +IDR_ZEN_JAR RCDATA "zen.jar" diff --git a/native/dll/res/resource.h b/native/dll/res/resource.h new file mode 100644 index 0000000..d1b72ab --- /dev/null +++ b/native/dll/res/resource.h @@ -0,0 +1,3 @@ +#pragma once + +#define IDR_ZEN_JAR 101 diff --git a/native/dll/src/class_loader.cpp b/native/dll/src/class_loader.cpp new file mode 100644 index 0000000..91e4a3a --- /dev/null +++ b/native/dll/src/class_loader.cpp @@ -0,0 +1,138 @@ +#include "openzen.h" + +#include + +namespace openzen::classes { + +namespace { + bool check_and_clear(JNIEnv* env, const char* where) { + if (env->ExceptionCheck()) { + log::error("JNI exception at %s", where); + env->ExceptionDescribe(); + env->ExceptionClear(); + return true; + } + return false; + } + + jvmtiEnv* get_jvmti(JavaVM* vm) { + jvmtiEnv* jvmti = nullptr; + if (vm->GetEnv((void**)&jvmti, JVMTI_VERSION_1_2) != JNI_OK || !jvmti) { + log::error("GetEnv(JVMTI_VERSION_1_2) failed"); + return nullptr; + } + return jvmti; + } + + jobject class_loader_of(JNIEnv* env, jclass cls) { + jclass classCls = env->FindClass("java/lang/Class"); + if (!classCls) return nullptr; + jmethodID mid = env->GetMethodID(classCls, "getClassLoader", + "()Ljava/lang/ClassLoader;"); + if (!mid) return nullptr; + jobject loader = env->CallObjectMethod(cls, mid); + env->DeleteLocalRef(classCls); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return nullptr; + } + return loader; + } +} + +jobject find_game_class_loader(JavaVM* vm, JNIEnv* env) { + jvmtiEnv* jvmti = get_jvmti(vm); + if (!jvmti) return nullptr; + + jint count = 0; + jclass* classes = nullptr; + jvmtiError rc = jvmti->GetLoadedClasses(&count, &classes); + if (rc != JVMTI_ERROR_NONE || !classes) { + log::error("GetLoadedClasses failed: %d", (int)rc); + return nullptr; + } + log::info("GetLoadedClasses returned %d classes", (int)count); + + jobject game_loader = nullptr; + const char* needle = "Lnet/minecraft/client/Minecraft;"; + + for (jint i = 0; i < count && !game_loader; ++i) { + char* sig = nullptr; + if (jvmti->GetClassSignature(classes[i], &sig, nullptr) != JVMTI_ERROR_NONE) continue; + if (sig && std::strcmp(sig, needle) == 0) { + log::info("Matched class signature %s", sig); + game_loader = class_loader_of(env, classes[i]); + } + if (sig) jvmti->Deallocate((unsigned char*)sig); + } + + jvmti->Deallocate((unsigned char*)classes); + + if (!game_loader) { + log::error("Minecraft class not found among loaded classes"); + return nullptr; + } + return game_loader; +} + +jclass load_dll_bootstrap(JNIEnv* env, jobject game_loader, + const std::wstring& jar_path) { + // Build java.io.File(jar_path) + jclass fileCls = env->FindClass("java/io/File"); + if (!fileCls) { check_and_clear(env, "FindClass File"); return nullptr; } + jmethodID fileCtor = env->GetMethodID(fileCls, "", "(Ljava/lang/String;)V"); + if (!fileCtor) { check_and_clear(env, "GetMethodID File."); return nullptr; } + jstring jarStr = env->NewString( + reinterpret_cast(jar_path.c_str()), + static_cast(jar_path.size())); + jobject file = env->NewObject(fileCls, fileCtor, jarStr); + if (check_and_clear(env, "new File")) return nullptr; + + // file.toURI() + jmethodID toURI = env->GetMethodID(fileCls, "toURI", "()Ljava/net/URI;"); + jobject uri = env->CallObjectMethod(file, toURI); + if (check_and_clear(env, "File.toURI")) return nullptr; + + // uri.toURL() + jclass uriCls = env->FindClass("java/net/URI"); + jmethodID toURL = env->GetMethodID(uriCls, "toURL", "()Ljava/net/URL;"); + jobject url = env->CallObjectMethod(uri, toURL); + if (check_and_clear(env, "URI.toURL")) return nullptr; + + // URL[] urls = { url }; + jclass urlCls = env->FindClass("java/net/URL"); + jobjectArray urls = env->NewObjectArray(1, urlCls, url); + if (check_and_clear(env, "NewObjectArray URL[]")) return nullptr; + + // new URLClassLoader(urls, gameLoader) + jclass urlclCls = env->FindClass("java/net/URLClassLoader"); + if (!urlclCls) { check_and_clear(env, "FindClass URLClassLoader"); return nullptr; } + jmethodID urlclCtor = env->GetMethodID(urlclCls, "", + "([Ljava/net/URL;Ljava/lang/ClassLoader;)V"); + if (!urlclCtor) { check_and_clear(env, "GetMethodID URLClassLoader."); return nullptr; } + jobject urlcl = env->NewObject(urlclCls, urlclCtor, urls, game_loader); + if (check_and_clear(env, "new URLClassLoader")) return nullptr; + log::info("URLClassLoader constructed with parent=gameLoader"); + + // urlcl.loadClass("shit.zen.dll.GameLoaderBridge") + // + // We deliberately load GameLoaderBridge - not DllBootstrap - because the + // bridge's job is to re-define every class in zen.jar onto the game + // class loader so retransformed Minecraft classes can resolve our patch + // handlers (defining-loader equality). DllBootstrap is then loaded by + // the game loader from the bridge. + jclass classLoaderCls = env->FindClass("java/lang/ClassLoader"); + jmethodID loadClass = env->GetMethodID(classLoaderCls, "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;"); + jstring name = env->NewStringUTF("shit.zen.dll.GameLoaderBridge"); + jobject loaded = env->CallObjectMethod(urlcl, loadClass, name); + if (check_and_clear(env, "URLClassLoader.loadClass GameLoaderBridge")) return nullptr; + if (!loaded) { + log::error("loadClass returned null for GameLoaderBridge"); + return nullptr; + } + log::info("GameLoaderBridge class loaded via URLClassLoader"); + return static_cast(loaded); +} + +} // namespace openzen::classes diff --git a/native/dll/src/diagnostics.cpp b/native/dll/src/diagnostics.cpp new file mode 100644 index 0000000..1fae2e6 --- /dev/null +++ b/native/dll/src/diagnostics.cpp @@ -0,0 +1,69 @@ +#include "openzen.h" + +#include +#include +#include + +namespace openzen::log { + +namespace { + std::mutex g_mutex; + HANDLE g_file = INVALID_HANDLE_VALUE; + + void write_line(const char* level, const char* fmt, va_list ap) { + std::lock_guard lock(g_mutex); + + SYSTEMTIME st; + GetLocalTime(&st); + + char buf[2048]; + int prefix = std::snprintf(buf, sizeof buf, + "[%02d:%02d:%02d.%03d %s] ", + st.wHour, st.wMinute, st.wSecond, + st.wMilliseconds, level); + if (prefix < 0) prefix = 0; + int body = std::vsnprintf(buf + prefix, sizeof buf - prefix - 2, fmt, ap); + if (body < 0) body = 0; + int total = prefix + body; + if (total > (int)sizeof buf - 2) total = (int)sizeof buf - 2; + buf[total++] = '\r'; + buf[total++] = '\n'; + + OutputDebugStringA(buf); + + if (g_file != INVALID_HANDLE_VALUE) { + DWORD written = 0; + WriteFile(g_file, buf, (DWORD)total, &written, nullptr); + FlushFileBuffers(g_file); + } + } +} + +void init() { + std::lock_guard lock(g_mutex); + if (g_file != INVALID_HANDLE_VALUE) return; + + wchar_t tmp[MAX_PATH]; + DWORD n = GetTempPathW(MAX_PATH, tmp); + if (n == 0 || n > MAX_PATH) return; + + wchar_t path[MAX_PATH]; + std::swprintf(path, MAX_PATH, L"%sopenzen.log", tmp); + + g_file = CreateFileW(path, GENERIC_WRITE, FILE_SHARE_READ, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); +} + +void info(const char* fmt, ...) { + va_list ap; va_start(ap, fmt); + write_line("INFO", fmt, ap); + va_end(ap); +} + +void error(const char* fmt, ...) { + va_list ap; va_start(ap, fmt); + write_line("ERROR", fmt, ap); + va_end(ap); +} + +} // namespace openzen::log diff --git a/native/dll/src/jar_extract.cpp b/native/dll/src/jar_extract.cpp new file mode 100644 index 0000000..a93b784 --- /dev/null +++ b/native/dll/src/jar_extract.cpp @@ -0,0 +1,57 @@ +#include "openzen.h" +#include "resource.h" + +namespace openzen::jar { + +bool extract_embedded(std::wstring& out_path) { + HRSRC info = FindResourceW(g_self_module, MAKEINTRESOURCEW(IDR_ZEN_JAR), RT_RCDATA); + if (!info) { + log::error("FindResource(IDR_ZEN_JAR) failed: %lu", GetLastError()); + return false; + } + DWORD size = SizeofResource(g_self_module, info); + if (size == 0) { + log::error("Embedded zen.jar resource is empty"); + return false; + } + HGLOBAL loaded = LoadResource(g_self_module, info); + if (!loaded) { + log::error("LoadResource failed: %lu", GetLastError()); + return false; + } + void* data = LockResource(loaded); + if (!data) { + log::error("LockResource failed"); + return false; + } + + wchar_t tmp[MAX_PATH]; + if (GetTempPathW(MAX_PATH, tmp) == 0) { + log::error("GetTempPath failed: %lu", GetLastError()); + return false; + } + + wchar_t path[MAX_PATH]; + std::swprintf(path, MAX_PATH, L"%sopenzen-%lu.jar", tmp, GetCurrentProcessId()); + + HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + log::error("CreateFile %ls failed: %lu", path, GetLastError()); + return false; + } + + DWORD written = 0; + BOOL ok = WriteFile(file, data, size, &written, nullptr); + CloseHandle(file); + if (!ok || written != size) { + log::error("WriteFile %ls failed: %lu", path, GetLastError()); + return false; + } + + out_path.assign(path); + log::info("Extracted zen.jar (%lu bytes) to %ls", (unsigned long)size, path); + return true; +} + +} // namespace openzen::jar diff --git a/native/dll/src/jvm_attach.cpp b/native/dll/src/jvm_attach.cpp new file mode 100644 index 0000000..538537a --- /dev/null +++ b/native/dll/src/jvm_attach.cpp @@ -0,0 +1,102 @@ +#include "openzen.h" + +#include + +namespace openzen::jvm { + +namespace { + // Signature of Agent_OnAttach exported by the JDK's instrument.dll. + using Agent_OnAttach_t = jint (JNICALL*)(JavaVM* vm, char* options, void* reserved); + + bool find_instrument_dll(std::wstring& out_path) { + // The JDK puts instrument.dll next to jvm.dll / java.dll. We locate + // java.dll (loaded by the running JVM) and rewrite the filename. + HMODULE javaDll = GetModuleHandleW(L"java.dll"); + if (!javaDll) { + log::error("java.dll not loaded in current process"); + return false; + } + wchar_t path[MAX_PATH]; + DWORD n = GetModuleFileNameW(javaDll, path, MAX_PATH); + if (n == 0 || n >= MAX_PATH) { + log::error("GetModuleFileName(java.dll) failed: %lu", GetLastError()); + return false; + } + // Walk back to last backslash and append instrument.dll. + wchar_t* slash = wcsrchr(path, L'\\'); + if (!slash) { + log::error("Unexpected java.dll path: %ls", path); + return false; + } + slash[1] = L'\0'; + out_path.assign(path); + out_path.append(L"instrument.dll"); + return true; + } +} + +JavaVM* find_vm() { + // We are injected into a process that already loaded jvm.dll. We resolve + // JNI_GetCreatedJavaVMs at runtime so the DLL does not have to link + // against a specific JDK's jvm.lib at build time. + HMODULE jvm_dll = GetModuleHandleW(L"jvm.dll"); + if (!jvm_dll) { + log::error("jvm.dll is not loaded in the current process"); + return nullptr; + } + using JNI_GetCreatedJavaVMs_t = jint (JNICALL*)(JavaVM**, jsize, jsize*); + auto fn = reinterpret_cast( + GetProcAddress(jvm_dll, "JNI_GetCreatedJavaVMs")); + if (!fn) { + log::error("GetProcAddress(JNI_GetCreatedJavaVMs) failed: %lu", GetLastError()); + return nullptr; + } + JavaVM* vm = nullptr; + jsize count = 0; + jint rc = fn(&vm, 1, &count); + if (rc != JNI_OK || count < 1 || !vm) { + log::error("JNI_GetCreatedJavaVMs rc=%d count=%d", (int)rc, (int)count); + return nullptr; + } + return vm; +} + +jint attach_instrument(JavaVM* vm, const std::wstring& jar_path) { + std::wstring instrument_path; + if (!find_instrument_dll(instrument_path)) { + return -1; + } + log::info("Loading %ls", instrument_path.c_str()); + + HMODULE inst = LoadLibraryW(instrument_path.c_str()); + if (!inst) { + log::error("LoadLibrary instrument.dll failed: %lu", GetLastError()); + return -1; + } + + auto fn = reinterpret_cast(GetProcAddress(inst, "Agent_OnAttach")); + if (!fn) { + log::error("GetProcAddress(Agent_OnAttach) failed: %lu", GetLastError()); + return -1; + } + + // OpenJDK's instrument.dll parses the options string using parseArgumentTail: + // the tail starts with the jar path (system encoding), optionally followed + // by '=' and additional agent args. We pass only the jar path. + int needed = WideCharToMultiByte(CP_ACP, 0, jar_path.c_str(), -1, + nullptr, 0, nullptr, nullptr); + if (needed <= 0) { + log::error("WideCharToMultiByte sizing failed: %lu", GetLastError()); + return -1; + } + std::vector options(needed); + WideCharToMultiByte(CP_ACP, 0, jar_path.c_str(), -1, + options.data(), needed, nullptr, nullptr); + + log::info("Calling Agent_OnAttach with options=%s", options.data()); + jint rc = fn(vm, options.data(), nullptr); + log::info("Agent_OnAttach returned %d", (int)rc); + return rc; +} + +} // namespace openzen::jvm diff --git a/native/dll/src/main.cpp b/native/dll/src/main.cpp new file mode 100644 index 0000000..caea5ed --- /dev/null +++ b/native/dll/src/main.cpp @@ -0,0 +1,107 @@ +#include "openzen.h" + +#include + +namespace openzen { + HMODULE g_self_module = nullptr; +} + +namespace { + +std::atomic g_already_attached{false}; + +DWORD WINAPI inject_thread(LPVOID) { + using namespace openzen; + + log::init(); + log::info("OpenZen.dll bootstrap thread started, pid=%lu", GetCurrentProcessId()); + + JavaVM* vm = jvm::find_vm(); + if (!vm) return 1; + + JNIEnv* env = nullptr; + JavaVMAttachArgs args{}; + args.version = JNI_VERSION_1_8; + args.name = const_cast("OpenZen-Bootstrap"); + args.group = nullptr; + if (vm->AttachCurrentThreadAsDaemon((void**)&env, &args) != JNI_OK || !env) { + log::error("AttachCurrentThreadAsDaemon failed"); + return 2; + } + log::info("Attached bootstrap thread to JavaVM"); + + std::wstring jar_path; + if (!jar::extract_embedded(jar_path)) { + vm->DetachCurrentThread(); + return 3; + } + + jint rc = jvm::attach_instrument(vm, jar_path); + if (rc != 0) { + log::error("Agent_OnAttach reported error %d", (int)rc); + // Continue anyway - some JDK builds report non-zero even on success + // because of secondary cleanup; PatchAgent.agentmain may still have run. + } + + jobject game_loader = classes::find_game_class_loader(vm, env); + if (!game_loader) { + vm->DetachCurrentThread(); + return 4; + } + + jclass bridge_cls = classes::load_dll_bootstrap(env, game_loader, jar_path); + if (!bridge_cls) { + env->DeleteLocalRef(game_loader); + vm->DetachCurrentThread(); + return 5; + } + + jmethodID load_mid = env->GetStaticMethodID(bridge_cls, "load", + "(Ljava/lang/String;Ljava/lang/ClassLoader;)V"); + if (!load_mid) { + log::error("GameLoaderBridge.load(String, ClassLoader) method not found"); + env->ExceptionClear(); + vm->DetachCurrentThread(); + return 6; + } + + jstring jar_jstr = env->NewString( + reinterpret_cast(jar_path.c_str()), + static_cast(jar_path.size())); + + env->CallStaticVoidMethod(bridge_cls, load_mid, jar_jstr, game_loader); + if (env->ExceptionCheck()) { + log::error("GameLoaderBridge.load threw an exception"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + log::info("GameLoaderBridge.load returned without exception"); + } + + env->DeleteLocalRef(jar_jstr); + env->DeleteLocalRef(bridge_cls); + env->DeleteLocalRef(game_loader); + vm->DetachCurrentThread(); + return 0; +} + +} // namespace + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + // Idempotence: if the loader injects twice (or the host calls + // LoadLibrary twice from different threads) we still only kick off the + // bootstrap once. + bool expected = false; + if (!g_already_attached.compare_exchange_strong(expected, true)) { + return TRUE; + } + openzen::g_self_module = module; + DisableThreadLibraryCalls(module); + // Never call JNI from inside DllMain - the loader lock is held. Kick + // a separate worker thread that will do all the heavy lifting. + HANDLE t = CreateThread(nullptr, 0, inject_thread, nullptr, 0, nullptr); + if (t) CloseHandle(t); + } + return TRUE; +} diff --git a/native/dll/src/openzen.h b/native/dll/src/openzen.h new file mode 100644 index 0000000..9f0f9e6 --- /dev/null +++ b/native/dll/src/openzen.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include + +namespace openzen { + +extern HMODULE g_self_module; + +namespace log { + void init(); + void info(const char* fmt, ...); + void error(const char* fmt, ...); +} + +namespace jar { + // Extract the IDR_ZEN_JAR resource embedded in OpenZen.dll into a temporary + // file under %TEMP%. Returns the absolute path on success. + bool extract_embedded(std::wstring& out_path); +} + +namespace jvm { + // Locate the running JavaVM in the current process. Returns nullptr if no + // JVM is available (the DLL was injected into a non-Java process). + JavaVM* find_vm(); + + // Call Agent_OnAttach in the JDK's instrument.dll, pointing it at the given + // agent jar. After this returns 0 the jar's Agent-Class entry point + // (PatchAgent.agentmain) will have been invoked and the JDK's + // InstrumentationImpl will be live. + jint attach_instrument(JavaVM* vm, const std::wstring& jar_path); +} + +namespace classes { + // Walk loaded classes via JVMTI to find the class loader that defined + // net.minecraft.client.Minecraft - the Forge GameClassLoader. Returns a + // local JNI reference (caller manages lifetime). + jobject find_game_class_loader(JavaVM* vm, JNIEnv* env); + + // Build URLClassLoader(jar, parent=gameLoader) and load DllBootstrap. + // Returns a local JNI reference to the class. + jclass load_dll_bootstrap(JNIEnv* env, jobject game_loader, + const std::wstring& jar_path); +} + +} // namespace openzen diff --git a/native/loader/CMakeLists.txt b/native/loader/CMakeLists.txt new file mode 100644 index 0000000..5a5c58f --- /dev/null +++ b/native/loader/CMakeLists.txt @@ -0,0 +1,51 @@ +# Stage the freshly-built OpenZen.dll into the loader's binary dir so rc.exe +# can pick it up as an RCDATA resource (embedded in the .exe). +set(EMBED_DIR "${CMAKE_CURRENT_BINARY_DIR}/embedded_dll") +add_custom_command( + OUTPUT "${EMBED_DIR}/OpenZen.dll" + COMMAND ${CMAKE_COMMAND} -E make_directory "${EMBED_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy $ "${EMBED_DIR}/OpenZen.dll" + DEPENDS OpenZen + COMMENT "Staging OpenZen.dll into loader resources" +) +add_custom_target(stage_dll_for_loader DEPENDS "${EMBED_DIR}/OpenZen.dll") + +add_executable(OpenZenLoader WIN32 + src/main.cpp + src/process_list.cpp + src/injector.cpp + src/embedded_dll.cpp + src/window_title.cpp + src/ui.cpp + res/loader.rc +) + +add_dependencies(OpenZenLoader stage_dll_for_loader) + +target_include_directories(OpenZenLoader PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/res +) + +# Tell rc.exe to look in EMBED_DIR (where OpenZen.dll was staged) when +# resolving the RCDATA "OpenZen.dll" reference in loader.rc. +set_source_files_properties(res/loader.rc PROPERTIES + COMPILE_FLAGS "/I\"${EMBED_DIR}\"" +) + +target_link_libraries(OpenZenLoader PRIVATE + comctl32 + psapi + shlwapi + user32 +) + +set_target_properties(OpenZenLoader PROPERTIES + OUTPUT_NAME "OpenZenLoader" +) + +if(MSVC) + set_target_properties(OpenZenLoader PROPERTIES + LINK_FLAGS "/SUBSYSTEM:WINDOWS /MANIFESTUAC:\"level='asInvoker' uiAccess='false'\"" + ) +endif() diff --git a/native/loader/res/loader.rc b/native/loader/res/loader.rc new file mode 100644 index 0000000..b19c152 --- /dev/null +++ b/native/loader/res/loader.rc @@ -0,0 +1,3 @@ +#include "resource.h" + +IDR_OPENZEN_DLL RCDATA "OpenZen.dll" diff --git a/native/loader/res/resource.h b/native/loader/res/resource.h new file mode 100644 index 0000000..6e1995d --- /dev/null +++ b/native/loader/res/resource.h @@ -0,0 +1,3 @@ +#pragma once + +#define IDR_OPENZEN_DLL 201 diff --git a/native/loader/src/embedded_dll.cpp b/native/loader/src/embedded_dll.cpp new file mode 100644 index 0000000..45f7d35 --- /dev/null +++ b/native/loader/src/embedded_dll.cpp @@ -0,0 +1,39 @@ +#include "loader.h" +#include "resource.h" + +namespace loader { + +std::wstring extract_embedded_dll() { + HMODULE self = GetModuleHandleW(nullptr); + HRSRC info = FindResourceW(self, MAKEINTRESOURCEW(IDR_OPENZEN_DLL), RT_RCDATA); + if (!info) return L""; + DWORD size = SizeofResource(self, info); + if (size == 0) return L""; + HGLOBAL loaded = LoadResource(self, info); + if (!loaded) return L""; + void* data = LockResource(loaded); + if (!data) return L""; + + wchar_t tmp[MAX_PATH]; + if (GetTempPathW(MAX_PATH, tmp) == 0) return L""; + + wchar_t dir[MAX_PATH]; + std::swprintf(dir, MAX_PATH, L"%sOpenZenLoader", tmp); + CreateDirectoryW(dir, nullptr); + + wchar_t path[MAX_PATH]; + std::swprintf(path, MAX_PATH, L"%s\\OpenZen.dll", dir); + + HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return L""; + + DWORD written = 0; + BOOL ok = WriteFile(file, data, size, &written, nullptr); + CloseHandle(file); + if (!ok || written != size) return L""; + + return std::wstring(path); +} + +} // namespace loader diff --git a/native/loader/src/injector.cpp b/native/loader/src/injector.cpp new file mode 100644 index 0000000..91b0bc7 --- /dev/null +++ b/native/loader/src/injector.cpp @@ -0,0 +1,88 @@ +#include "loader.h" + +#include + +#include + +namespace loader { + +namespace { + std::wstring format_error(const wchar_t* where, DWORD err) { + wchar_t msg[512] = {0}; + FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, err, 0, msg, 512, nullptr); + std::wstringstream ss; + ss << where << L" failed (code " << err << L"): " << msg; + return ss.str(); + } +} + +std::wstring inject(DWORD pid, const std::wstring& dll_path) { + if (!PathFileExistsW(dll_path.c_str())) { + return L"DLL not found: " + dll_path; + } + + HANDLE process = OpenProcess( + PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, + FALSE, pid); + if (!process) return format_error(L"OpenProcess", GetLastError()); + + SIZE_T payload_bytes = (dll_path.size() + 1) * sizeof(wchar_t); + LPVOID remote = VirtualAllocEx(process, nullptr, payload_bytes, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!remote) { + DWORD err = GetLastError(); + CloseHandle(process); + return format_error(L"VirtualAllocEx", err); + } + + SIZE_T written = 0; + if (!WriteProcessMemory(process, remote, dll_path.c_str(), payload_bytes, &written) + || written != payload_bytes) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return format_error(L"WriteProcessMemory", err); + } + + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + if (!kernel32) { + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return L"kernel32.dll handle missing in loader process"; + } + LPTHREAD_START_ROUTINE loadLib = + (LPTHREAD_START_ROUTINE)GetProcAddress(kernel32, "LoadLibraryW"); + if (!loadLib) { + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return L"LoadLibraryW not exported by kernel32"; + } + + HANDLE remote_thread = CreateRemoteThread(process, nullptr, 0, loadLib, + remote, 0, nullptr); + if (!remote_thread) { + DWORD err = GetLastError(); + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return format_error(L"CreateRemoteThread", err); + } + + WaitForSingleObject(remote_thread, 10000); + + DWORD exit_code = 0; + GetExitCodeThread(remote_thread, &exit_code); + + CloseHandle(remote_thread); + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + + if (exit_code == 0) { + return L"LoadLibraryW remote returned NULL (DLL failed to load - " + L"check %TEMP%\\openzen.log)"; + } + return L""; +} + +} // namespace loader diff --git a/native/loader/src/loader.h b/native/loader/src/loader.h new file mode 100644 index 0000000..7a0bff4 --- /dev/null +++ b/native/loader/src/loader.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace loader { + +struct JavaProcess { + DWORD pid; + std::wstring image_name; + std::wstring command_line; + std::wstring window_title; +}; + +// Enumerate processes whose image is javaw.exe / java.exe. +std::vector list_java_processes(); + +// Inject the given DLL into the target process via CreateRemoteThread + +// LoadLibraryW. Returns an empty string on success or a human-readable error. +std::wstring inject(DWORD pid, const std::wstring& dll_path); + +// Extract the IDR_OPENZEN_DLL resource baked into the loader EXE to a +// temp file and return its absolute path. Empty string on failure. +std::wstring extract_embedded_dll(); + +// Walk top-level windows and return the most informative title belonging to +// the given pid ("" if none found). +std::wstring window_title_for(DWORD pid); + +int run_ui(HINSTANCE hInstance); + +} // namespace loader diff --git a/native/loader/src/main.cpp b/native/loader/src/main.cpp new file mode 100644 index 0000000..4c5cb3b --- /dev/null +++ b/native/loader/src/main.cpp @@ -0,0 +1,5 @@ +#include "loader.h" + +int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int) { + return loader::run_ui(hInstance); +} diff --git a/native/loader/src/process_list.cpp b/native/loader/src/process_list.cpp new file mode 100644 index 0000000..3ce2b3e --- /dev/null +++ b/native/loader/src/process_list.cpp @@ -0,0 +1,69 @@ +#include "loader.h" + +#include +#include + +#include +#include + +namespace loader { + +namespace { + bool ci_equals(const std::wstring& a, const wchar_t* b) { + std::wstring lower(a); + std::transform(lower.begin(), lower.end(), lower.begin(), + [](wchar_t c) { return (wchar_t)std::towlower(c); }); + return lower == b; + } + + std::wstring read_command_line(HANDLE process) { + // Reading the full command line via NtQueryInformationProcess / PEB is + // architecture-sensitive and not worth the complexity here. We surface + // the executable's full path as a stand-in - users can identify the + // Minecraft instance from PID + working directory if needed. + wchar_t buf[MAX_PATH * 2]; + DWORD size = (DWORD)(sizeof buf / sizeof buf[0]); + if (QueryFullProcessImageNameW(process, 0, buf, &size)) { + return std::wstring(buf, size); + } + return L""; + } +} + +std::vector list_java_processes() { + std::vector result; + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return result; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof pe; + if (!Process32FirstW(snap, &pe)) { + CloseHandle(snap); + return result; + } + + do { + std::wstring name = pe.szExeFile; + if (!ci_equals(name, L"javaw.exe") && !ci_equals(name, L"java.exe")) continue; + + JavaProcess jp; + jp.pid = pe.th32ProcessID; + jp.image_name = name; + + HANDLE process = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, jp.pid); + if (process) { + jp.command_line = read_command_line(process); + CloseHandle(process); + } + jp.window_title = window_title_for(jp.pid); + result.push_back(std::move(jp)); + } while (Process32NextW(snap, &pe)); + + CloseHandle(snap); + return result; +} + +} // namespace loader diff --git a/native/loader/src/ui.cpp b/native/loader/src/ui.cpp new file mode 100644 index 0000000..aabac5c --- /dev/null +++ b/native/loader/src/ui.cpp @@ -0,0 +1,259 @@ +#include "loader.h" + +#include +#include + +#include +#include + +#pragma comment(lib, "comctl32.lib") + +namespace loader { + +namespace { + +constexpr wchar_t WND_CLASS[] = L"OpenZenLoaderWnd"; + +enum { + ID_LIST = 1001, + ID_REFRESH, + ID_INJECT, + ID_BROWSE, + ID_DLLPATH, + ID_LOG, +}; + +HWND g_list = nullptr; +HWND g_refresh = nullptr; +HWND g_inject = nullptr; +HWND g_browse = nullptr; +HWND g_dll_edit = nullptr; +HWND g_log = nullptr; +std::vector g_processes; + +void append_log(const std::wstring& line) { + int len = GetWindowTextLengthW(g_log); + SendMessageW(g_log, EM_SETSEL, len, len); + std::wstring text = line + L"\r\n"; + SendMessageW(g_log, EM_REPLACESEL, FALSE, (LPARAM)text.c_str()); +} + +std::wstring get_dll_path() { + int len = GetWindowTextLengthW(g_dll_edit); + std::wstring s(len, L'\0'); + if (len > 0) GetWindowTextW(g_dll_edit, s.data(), len + 1); + return s; +} + +void refresh_list() { + ListView_DeleteAllItems(g_list); + g_processes = list_java_processes(); + for (size_t i = 0; i < g_processes.size(); ++i) { + const auto& jp = g_processes[i]; + + LVITEMW item{}; + item.mask = LVIF_TEXT | LVIF_PARAM; + item.iItem = (int)i; + item.iSubItem = 0; + wchar_t pid_text[32]; + std::swprintf(pid_text, 32, L"%lu", jp.pid); + item.pszText = pid_text; + item.lParam = (LPARAM)i; + int row = ListView_InsertItem(g_list, &item); + + ListView_SetItemText(g_list, row, 1, + const_cast(jp.image_name.c_str())); + ListView_SetItemText(g_list, row, 2, + const_cast(jp.window_title.c_str())); + ListView_SetItemText(g_list, row, 3, + const_cast(jp.command_line.c_str())); + } + wchar_t msg[64]; + std::swprintf(msg, 64, L"Refreshed: %zu Java process(es)", g_processes.size()); + append_log(msg); +} + +void do_inject() { + int sel = ListView_GetNextItem(g_list, -1, LVNI_SELECTED); + if (sel < 0) { + append_log(L"No process selected. Pick a row and try again."); + return; + } + LVITEMW item{}; + item.mask = LVIF_PARAM; + item.iItem = sel; + ListView_GetItem(g_list, &item); + size_t idx = (size_t)item.lParam; + if (idx >= g_processes.size()) { + append_log(L"Stale selection. Refresh and retry."); + return; + } + const auto& jp = g_processes[idx]; + + std::wstring dll = get_dll_path(); + wchar_t msg[512]; + std::swprintf(msg, 512, L"Injecting %ls into pid %lu (%ls)", + dll.c_str(), jp.pid, jp.image_name.c_str()); + append_log(msg); + + std::wstring err = inject(jp.pid, dll); + if (err.empty()) { + append_log(L"Injection ok. Watch %TEMP%\\openzen.log for Java side."); + } else { + append_log(L"Injection failed: " + err); + } +} + +void do_browse() { + wchar_t buf[MAX_PATH] = {0}; + std::wstring current = get_dll_path(); + if (!current.empty() && current.size() < MAX_PATH) { + wcscpy_s(buf, MAX_PATH, current.c_str()); + } + + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof ofn; + ofn.hwndOwner = GetParent(g_browse); + ofn.lpstrFilter = L"DLL Files\0*.dll\0All Files\0*.*\0"; + ofn.lpstrFile = buf; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; + + if (GetOpenFileNameW(&ofn)) { + SetWindowTextW(g_dll_edit, buf); + } +} + +void layout(HWND hwnd) { + RECT rc; GetClientRect(hwnd, &rc); + int W = rc.right - rc.left; + int H = rc.bottom - rc.top; + + int pad = 8; + int btn_h = 24; + int row_h = 26; + + // Top row: DLL path edit + Browse + MoveWindow(g_dll_edit, pad, pad, W - pad*3 - 80, btn_h, TRUE); + MoveWindow(g_browse, W - pad - 80, pad, 80, btn_h, TRUE); + + // Button row + int by = pad + row_h; + MoveWindow(g_refresh, pad, by, 100, btn_h, TRUE); + MoveWindow(g_inject, pad + 110, by, 100, btn_h, TRUE); + + // List view + int list_y = by + row_h; + int list_h = H * 60 / 100 - list_y; + if (list_h < 100) list_h = 100; + MoveWindow(g_list, pad, list_y, W - pad*2, list_h, TRUE); + + // Log area + int log_y = list_y + list_h + pad; + int log_h = H - log_y - pad; + if (log_h < 60) log_h = 60; + MoveWindow(g_log, pad, log_y, W - pad*2, log_h, TRUE); +} + +LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_CREATE: { + HINSTANCE hi = ((LPCREATESTRUCT)lp)->hInstance; + + g_dll_edit = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL, + 0, 0, 0, 0, hwnd, (HMENU)ID_DLLPATH, hi, nullptr); + // Extract the embedded OpenZen.dll to %TEMP%\OpenZenLoader and pre-fill + // it as the default DLL path. Users can still override via Browse. + std::wstring embedded = extract_embedded_dll(); + if (!embedded.empty()) { + SetWindowTextW(g_dll_edit, embedded.c_str()); + } + + g_browse = CreateWindowW(L"BUTTON", L"Browse...", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, + 0, 0, 0, 0, hwnd, (HMENU)ID_BROWSE, hi, nullptr); + + g_refresh = CreateWindowW(L"BUTTON", L"Refresh", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, + 0, 0, 0, 0, hwnd, (HMENU)ID_REFRESH, hi, nullptr); + g_inject = CreateWindowW(L"BUTTON", L"Inject", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, + 0, 0, 0, 0, hwnd, (HMENU)ID_INJECT, hi, nullptr); + + g_list = CreateWindowExW(WS_EX_CLIENTEDGE, WC_LISTVIEWW, L"", + WS_CHILD | WS_VISIBLE | LVS_REPORT | LVS_SINGLESEL, + 0, 0, 0, 0, hwnd, (HMENU)ID_LIST, hi, nullptr); + ListView_SetExtendedListViewStyle(g_list, + LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); + + LVCOLUMNW col{}; + col.mask = LVCF_TEXT | LVCF_WIDTH; + col.cx = 70; col.pszText = const_cast(L"PID"); + ListView_InsertColumn(g_list, 0, &col); + col.cx = 100; col.pszText = const_cast(L"Image"); + ListView_InsertColumn(g_list, 1, &col); + col.cx = 320; col.pszText = const_cast(L"Window Title"); + ListView_InsertColumn(g_list, 2, &col); + col.cx = 300; col.pszText = const_cast(L"Path"); + ListView_InsertColumn(g_list, 3, &col); + + g_log = CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", L"", + WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE + | ES_AUTOVSCROLL | ES_READONLY, + 0, 0, 0, 0, hwnd, (HMENU)ID_LOG, hi, nullptr); + + layout(hwnd); + refresh_list(); + append_log(L"OpenZen Loader ready. Select a javaw.exe and press Inject."); + return 0; + } + case WM_SIZE: layout(hwnd); return 0; + case WM_COMMAND: + switch (LOWORD(wp)) { + case ID_REFRESH: refresh_list(); return 0; + case ID_INJECT: do_inject(); return 0; + case ID_BROWSE: do_browse(); return 0; + } + break; + case WM_DESTROY: PostQuitMessage(0); return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +} // namespace + +int run_ui(HINSTANCE hInstance) { + INITCOMMONCONTROLSEX icc{}; + icc.dwSize = sizeof icc; + icc.dwICC = ICC_LISTVIEW_CLASSES | ICC_STANDARD_CLASSES; + InitCommonControlsEx(&icc); + + WNDCLASSEXW wc{}; + wc.cbSize = sizeof wc; + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = wnd_proc; + wc.hInstance = hInstance; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); + wc.lpszClassName = WND_CLASS; + RegisterClassExW(&wc); + + HWND hwnd = CreateWindowExW(0, WND_CLASS, L"OpenZen Loader", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, 820, 560, + nullptr, nullptr, hInstance, nullptr); + if (!hwnd) return 1; + + ShowWindow(hwnd, SW_SHOW); + UpdateWindow(hwnd); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + return (int)msg.wParam; +} + +} // namespace loader diff --git a/native/loader/src/window_title.cpp b/native/loader/src/window_title.cpp new file mode 100644 index 0000000..f48342d --- /dev/null +++ b/native/loader/src/window_title.cpp @@ -0,0 +1,43 @@ +#include "loader.h" + +namespace loader { + +namespace { + struct Search { + DWORD pid; + std::wstring best; + }; + + BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lp) { + Search* s = reinterpret_cast(lp); + + if (!IsWindowVisible(hwnd)) return TRUE; + // Skip child windows / tool windows: we want main app windows. + if (GetWindow(hwnd, GW_OWNER) != nullptr) return TRUE; + + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != s->pid) return TRUE; + + int len = GetWindowTextLengthW(hwnd); + if (len <= 0) return TRUE; + std::wstring title(len, L'\0'); + GetWindowTextW(hwnd, title.data(), len + 1); + title.resize(len); + + // Prefer the longest title - usually the main Minecraft window which + // includes version/world name vs a tiny "Java" tooltip window. + if (title.size() > s->best.size()) { + s->best = std::move(title); + } + return TRUE; + } +} + +std::wstring window_title_for(DWORD pid) { + Search s{pid, {}}; + EnumWindows(enum_proc, reinterpret_cast(&s)); + return s.best; +} + +} // namespace loader diff --git a/src/main/java/asm/patchify/loader/PatchAgent.java b/src/main/java/asm/patchify/loader/PatchAgent.java index bdefc6e..0a78389 100644 --- a/src/main/java/asm/patchify/loader/PatchAgent.java +++ b/src/main/java/asm/patchify/loader/PatchAgent.java @@ -21,6 +21,8 @@ public final class PatchAgent { public static final String INSTRUMENTATION_KEY = "asm.patchify.instrumentation"; private static final Logger LOGGER = LogManager.getLogger("PatchAgent"); + private static volatile boolean transformerInstalled = false; + private PatchAgent() { } @@ -53,7 +55,11 @@ public final class PatchAgent { * Install a transformer for the currently registered patches and retransform any patch target * that is already loaded. Called from mod code once {@link PatchRegistry} is populated. */ - public static void installPatchesAndRetransform() { + public static synchronized void installPatchesAndRetransform() { + if (transformerInstalled) { + LOGGER.info("Patches already installed; skipping duplicate retransform request"); + return; + } Instrumentation inst = getInstrumentation(); if (inst == null) { LOGGER.warn("PatchAgent not attached; cannot install patches"); @@ -61,6 +67,7 @@ public final class PatchAgent { } PatchClassFileTransformer transformer = new PatchClassFileTransformer(); inst.addTransformer(transformer, true); + transformerInstalled = true; List> retransform = new ArrayList<>(); for (Class patch : PatchRegistry.getPatches()) { asm.patchify.annotation.Patch ann = patch.getAnnotation(asm.patchify.annotation.Patch.class); diff --git a/src/main/java/asm/patchify/loader/PatchTransformer.java b/src/main/java/asm/patchify/loader/PatchTransformer.java index 1df1810..ada95c5 100644 --- a/src/main/java/asm/patchify/loader/PatchTransformer.java +++ b/src/main/java/asm/patchify/loader/PatchTransformer.java @@ -35,6 +35,7 @@ import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.VarInsnNode; +import shit.zen.asm.Bootstrap; import shit.zen.asm.ILocals; import shit.zen.asm.Invocation; import shit.zen.asm.InvocationImpl; @@ -71,10 +72,11 @@ public final class PatchTransformer { if (patchAnnotation == null) { throw new IllegalArgumentException(patchClass.getName() + " is not @Patch"); } + String patchTargetOwner = Type.getInternalName(patchAnnotation.value()); Map> handlersByTarget = new HashMap<>(); for (Method handler : patchClass.getDeclaredMethods()) { - collectHandler(patchClass, handler, handlersByTarget); + collectHandler(patchClass, patchTargetOwner, handler, handlersByTarget); } LOGGER.info("Loading patch {} -> {} ({} handler(s))", patchClass.getName(), target.name, @@ -121,7 +123,8 @@ public final class PatchTransformer { } } - private static void collectHandler(Class patchClass, Method handler, + private static void collectHandler(Class patchClass, String patchTargetOwner, + Method handler, Map> handlersByTarget) { if (!(handler.isAnnotationPresent(Inject.class) || handler.isAnnotationPresent(Overwrite.class) @@ -168,6 +171,11 @@ public final class PatchTransformer { throw new IllegalArgumentException("@ModifyLocals " + handler + " must take a single ILocals"); } } + // Patch annotations carry the mojmap method name (the jar was compiled + // against mojmap, and reobfJar does not rewrite string literals). In a + // production Forge environment the live class only has SRG names, so + // remap before matching against ClassNode.methods. + name = Bootstrap.remapMethod(patchTargetOwner, name, desc); handlersByTarget.computeIfAbsent(new MethodKey(name, desc), k -> new ArrayList<>()).add(handler); } @@ -331,7 +339,7 @@ public final class PatchTransformer { Type returnType = Type.getReturnType(method.desc); String[] split = ASMHelpers.splitOwnerName(invokeName); String invokeOwner = split[0]; - String invokeMethod = split[1]; + String invokeMethod = Bootstrap.remapMethod(split[0], split[1], invokeDesc); Slice slice = handler.getAnnotation(Inject.class).slice(); // PatchApplier.applyInvokeInject uses strict owner+name+desc matching. @@ -462,7 +470,7 @@ public final class PatchTransformer { String targetDesc = wrap.targetDesc(); String[] split = ASMHelpers.splitOwnerName(target); String targetOwner = split[0]; - String targetMethod = split[1]; + String targetMethod = Bootstrap.remapMethod(split[0], split[1], targetDesc); // Match what shit.zen.asm.PatchApplier did: accept either owner or name match, // so targets pointing at the declaring class (e.g. Entity/getYRot) still hit @@ -607,10 +615,11 @@ public final class PatchTransformer { } } else { String[] split = ASMHelpers.splitOwnerName(at.method()); + String invokeMethod = Bootstrap.remapMethod(split[0], split[1], at.desc()); for (AbstractInsnNode insn : method.instructions) { if (insn instanceof MethodInsnNode m && m.owner.equals(split[0]) - && m.name.equals(split[1]) + && m.name.equals(invokeMethod) && m.desc.equals(at.desc())) { insertBefore = insn; foundAnchor = true; @@ -714,16 +723,20 @@ public final class PatchTransformer { String[] startSplit = slice.start().method().isEmpty() ? null : ASMHelpers.splitOwnerName(slice.start().method()); String startDesc = slice.start().desc(); + String startName = startSplit == null ? null + : Bootstrap.remapMethod(startSplit[0], startSplit[1], startDesc); String[] endSplit = slice.end().method().isEmpty() ? null : ASMHelpers.splitOwnerName(slice.end().method()); String endDesc = slice.end().desc(); + String endName = endSplit == null ? null + : Bootstrap.remapMethod(endSplit[0], endSplit[1], endDesc); boolean foundStart = defaultStart; for (AbstractInsnNode insn : insns) { if (!foundStart && startSplit != null && insn instanceof MethodInsnNode m - && m.owner.equals(startSplit[0]) && m.name.equals(startSplit[1]) && m.desc.equals(startDesc)) { + && m.owner.equals(startSplit[0]) && m.name.equals(startName) && m.desc.equals(startDesc)) { foundStart = true; } else if (!defaultEnd && endSplit != null && insn instanceof MethodInsnNode m - && m.owner.equals(endSplit[0]) && m.name.equals(endSplit[1]) && m.desc.equals(endDesc)) { + && m.owner.equals(endSplit[0]) && m.name.equals(endName) && m.desc.equals(endDesc)) { break; } // PatchApplier appends every insn in the slice, not only filter matches. diff --git a/src/main/java/shit/zen/ZenClient.java b/src/main/java/shit/zen/ZenClient.java index d76c457..fdd8582 100644 --- a/src/main/java/shit/zen/ZenClient.java +++ b/src/main/java/shit/zen/ZenClient.java @@ -43,6 +43,7 @@ import shit.zen.patch.MinecraftPatch; import shit.zen.patch.PacketUtilsPatch; import shit.zen.patch.PlayerPatch; import shit.zen.patch.PlayerTabOverlayPatch; +import shit.zen.asm.Bootstrap; import shit.zen.utils.rotation.RotationHandler; @Mod(value = "hey") @@ -78,6 +79,18 @@ public class ZenClient extends ClientBase { } } + /** + * Explicit entry point used by the DLL injection path + * ({@code shit.zen.dll.DllBootstrap}). Constructs the singleton if not + * already present and returns it. + */ + public static synchronized ZenClient bootstrapForDll() { + if (instance == null) { + new ZenClient(); + } + return instance; + } + private void init() { try { username = System.getProperty("user.name", "Player"); @@ -102,6 +115,7 @@ public class ZenClient extends ClientBase { this.eventBus.register(this); this.commandManager.initCommands(); this.eventBus.register(new IntroAnimation()); + Bootstrap.init(); registerPatches(); if (PatchAgent.getInstrumentation() != null) { PatchAgent.installPatchesAndRetransform(); @@ -152,7 +166,7 @@ public class ZenClient extends ClientBase { for (String name : CLOUD_ASSET_NAMES) { File outFile = new File(targetDir, name); if (outFile.exists()) continue; - try (InputStream in = ZenClient.class.getResourceAsStream("/assets/zen/cloud_assets/" + name)) { + try (InputStream in = openCloudAsset(name)) { if (in == null) { logger.warn("Cloud asset missing on classpath: {}", name); continue; @@ -166,7 +180,24 @@ public class ZenClient extends ClientBase { } } - private static void registerPatches() { + private static InputStream openCloudAsset(String name) { + String classpath = "/assets/zen/cloud_assets/" + name; + InputStream is = ZenClient.class.getResourceAsStream(classpath); + if (is != null) return is; + String dir = System.getProperty("openzen.resources"); + if (dir != null) { + File f = new File(dir, "assets/zen/cloud_assets/" + name); + if (f.isFile()) { + try { + return new java.io.FileInputStream(f); + } catch (IOException ignored) { + } + } + } + return null; + } + + public static void registerPatches() { PatchRegistry.register(MinecraftPatch.class); PatchRegistry.register(LocalPlayerPatch.class); PatchRegistry.register(LivingEntityPatch.class); diff --git a/src/main/java/shit/zen/asm/Bootstrap.java b/src/main/java/shit/zen/asm/Bootstrap.java new file mode 100644 index 0000000000000000000000000000000000000000..ea6e316e6399f2ab1cc45333ec37ead352ed3107 GIT binary patch literal 8624 zcmeHM-EP}P7VdSPVyXpd%5f;C$pYQfc9S@<>(q_|+i8I<3S&f$q=_kxIy1DRqyl=? z2Uzq8^CbJ7nISop=U}hq1`c9N7H`A~0+>z6n(w4p9 z*F2RfeOir1LW}%N@LcGxmtP;qLacpwRL&}UZnfY*x`gu+->oBAf~9Q2wGlQwx4g`` z&iDPj>C&mJEYfeenVj(1nm6&&63C{q6q)8Dn`VU+rA<%j2Mw^&MHW6u|#$#EI z>Hotw>9HE0@DjRg_4Bc_@wrC%e;4JxBMcJ^HaG9EO?FgDD|sP}Fw9N_(^YBZRIsUf zGv%`nzy0IdnwMsz^i=2t zyP1fRxnpbsbrwP!rUgvStYVE02;WrLiOS7jBt?-Mq6Ma;4(za!gEd()UY5#oD^6#3k$YSfd-R{NokH$iK+vhm8O~&CI)i)VjE}i2jg2W8 zmWXtmLflN{f?1heE&4JzlT`!7`fxWmUqLYs5xp zgDH3zXkk>PGhqgcK1=}jYIz~$LEaC){qBPYOh|1~_JhDLBoi{}1ZmDODBjK#9&tDb z=;L`YL+Gq$unF5#nv?xkCx}Lo!(I?iSW+BfserBJwO}rASfSKaH8U`2%U+PSNd}a= z3{sNG(z5`s%GY?fU~nr%!R|AH98W++=Ay-=M($NW+4J)D*dx{(UB1P2*;{z&ag1A7 zFc(7(eB=~Tm{!i6fe*~Vomn+3WCs3AUU(;BPnEI=Z>Z<}9g4S^me<^(m{91~N0Aqi z>)7$>;o^hfwy3Zx&K>^lJ$LAK)dQEhW|QwlwRXfG-0 z+@-|uOqgtuvhR6OiAT=2&4lV$h%t&@qz8}jl)%YGH*Dq-g&@0FMju1QXxjY2-oRk;Ck!isaaZVq*5IkQUNqO^N^3_(TugVr|x zAkzm&&yPQq=UV9pNC!i-9LT-H0>Pj(n5Arl3WUHTQ}{9z6%aCqx6%!=*_b)I*9d(9 z;0y(>$b(x_nLwk$LJ;HvDUbBcOobSB`LCanN1^g~U#Jt!$9>89g&eVQp@xuSjugbF zLpiQgWwu&WRC?K8o}4^AI(&Wl`h39@#L_pY;QyI-96UKbe)?qpyLG-nVyun6b>8L; z_iv%|4$B(hz)SD5=I*mz2;3!Bu2q4A0DBLWDg-YPYQCv?hdjQE)L$6%k85OD0Sw*G zkvZ4n7P14{q>-c_($d0blaRndax(KWo4}tW@o=pqnhh}3zz{PqbrNyfM;f=4ENjO>3^jiHUThyY3 z1mq%N;nE_HyH{lL>rOj&BkiQq34b)T)M2;kUm-$>4U3ZTzHt_M(g=fT2t6Z}>t`;P ziu}a&eo1IqTjLX0&`}iC^b~C(jU6m`sm{?Qdj!wlW@6?#R@r~RzZ2^ZVtjp9)lp#=o9+Pn==fo$o8KSRAXRQ&=LXZ2z1Oz?wzAo~qMW*hFn52u6 z2C#gNZlWrRI)Skav#>va9&5m!9Umj;Xc(b+4fS_|$qa2>W@!ox{WML2xx~OU6dA8u zPgko2TI_HE88!D!rlH&d)YO~0+J1;qzE8e%>?YAIXBjH`ug{L7=P^FlpQ7aZ4uGNx z^MalSe+S)Al5z~8O|FHfzS2;%w)3P%3V(;qMkC6yh6uD#Q8xr=v%y9{Ox+mWBsjdq zdbjAC)Id5PVGwYq`^(-*Csrekp3)VZz9tqqLo$lY>JLXT)y9z=mkNuTb!6L0K=AEd z`>Q4oU-1sI-kV*u?ADtobC7gN#5rZb-F05S%p)m|&eqP_wFL3p3GK)P&W%P26CvE4 z+{^he6yeBrY~Od|cqn<$H?WXfQ!2@0zaVmSd6l%`LeE--(VdgGyC4G;En0%xbl)Y7 za}#iVk;NmO`K1@MK_fzUzm>keIJo?(zt*urD4+q28j+gB4LW4Ol7!xZGtVM|g6GG0 zf*f~S4Acn7J_MX%VjtTH($)@p*mu*vEaTRv2B816rehhkK@OB|pV(v@^*6h}^zi-~ z4<{5vHvtNS=*Gzg`{E12M>hu>efG;Qgoy5*ZO~0je~6~*mLy6N9(fxy?W z(Y1Jc*VLmn09OiGa9W|Y(n;?CYYy}RNTw7(bzkK2HU`azv?ubB0Bo6#%pS*-fBfT5 zAa+%&+RU()N_s4I^~SD^aVQ3%>KIvV2;d6#hxPwjSCnpi1b$G94vkN3^pRwI#~b&+BHyM<6i;6 z6B|l9Y?~aeL4x~_s>6+~;IAfSs-baffMk2C!Lr0<&?G=;Mqdlu(7(%;1E{q_fUZ^3 zl5V6~g(kNMgj4Zzg&B=xCrn5c>(b(CtBvJ<;>EtpdV`+d(Kue3Z4LH{0qI)z7!7Ih zz)pz>yZ|#No*(+2iy?Y!m^n5&2I5BLNhSwvwPT~uDR-SwcO$KH-2GFn}kR0 z2ppMg!2~ARs1~k`ng{FE*IFwIh);@|FaIA9Y3tJeI{~pWVcr@Kjgv;sM84DA)C9K< zLD|6kHyqoA$?80Og6@e^@}f2>ELq)mBT;7P_!Tzl#;svXq>U|xhHQrJ45NTn)p&ya z8A?t$7P*hYP5&JV%~?q?hCM)ynZE8MOEWNi=%EW=mE1r2vP6?nfxS7#7^TsHH4DBS zMW2`aIsb|2`CH64^&sz()PR`0OFL6+A81ysx8O0@q1*k{a1jK?UBB6M-_oj5{#*&H z2&qdId5TRgeO3#T(x&4C8#|)ncAMD26D}wdj_g`kzvlF-0Ggq&Lc|9pbPR6tUdP%6 z<|w5lK1pJSE$PcEOK7&Ln_Zfn=-Upsn&9ISy)s0T2=}`PcLuG#!dBRC|HJYucxn6RUEtl`9sK-qPm7LE5yKco-XUZD5&H90ya8}%1U7*vl!q=Xd z8C0NK>QEGT#D&L;wz4xfyQeEWjD{#s%N2;o2Z`HH6H7z@7${Iv>+hOfY9sn+*isAw zT0rcdw^8Xo3}Dvg&Taf=t=;S2YQY2gmYaO40skC>t>nf}ve%LV|FD&o@@ZtGCCq=S eX}XK&dKo9pncwoB>v3lV6pS3lL2c>9oqqym`!3A@ literal 0 HcmV?d00001 diff --git a/src/main/java/shit/zen/dll/DllBootstrap.java b/src/main/java/shit/zen/dll/DllBootstrap.java new file mode 100644 index 0000000..f29ba52 --- /dev/null +++ b/src/main/java/shit/zen/dll/DllBootstrap.java @@ -0,0 +1,61 @@ +package shit.zen.dll; + +import asm.patchify.loader.PatchAgent; +import net.minecraft.client.Minecraft; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import shit.zen.ZenClient; +import shit.zen.asm.Bootstrap; + +/** + * Final hand-off step on the DLL injection path. By the time {@link #start} is + * invoked, {@code GameLoaderBridge} has already re-defined every class in the + * jar onto the Forge GameClassLoader, so {@link ZenClient} and all 21 patch + * handler classes share Minecraft's class loader. + * + *

This entry point intentionally does not construct {@link ZenClient}. + * Once {@link PatchAgent#installPatchesAndRetransform()} returns, the + * retransformed {@code Minecraft.tick()} contains the injected + * {@code MinecraftPatch.onTick} prologue that lazily constructs {@link + * ZenClient} on the next tick. Letting the existing tick-driven lazy-init run + * keeps the DLL path and the mod path identical from {@code ZenClient.init} + * onwards.

+ */ +public final class DllBootstrap { + private static final Logger LOGGER = LogManager.getLogger("DllBootstrap"); + private static volatile boolean started = false; + + private DllBootstrap() { + } + + public static synchronized void start(String extractedJarPath) { + if (started) { + LOGGER.info("DllBootstrap.start ignored (already started)"); + return; + } + started = true; + try { + LOGGER.info("DllBootstrap.start jar={}", extractedJarPath); + LOGGER.info("DllBootstrap loader = {}", DllBootstrap.class.getClassLoader()); + LOGGER.info("ZenClient loader = {}", ZenClient.class.getClassLoader()); + LOGGER.info("Minecraft loader = {}", Minecraft.class.getClassLoader()); + LOGGER.info("PatchAgent inst = {}", PatchAgent.getInstrumentation()); + + // Load mojmap → SRG mappings before we attempt any retransform. + Bootstrap.init(); + + // Register the 21 patch classes and trigger retransform of the + // already-loaded Minecraft targets. After this returns, + // Minecraft.tick / LocalPlayer.tick / etc. carry the injected + // prologues; the next tick will invoke MinecraftPatch.onTick which + // performs the real ZenClient construction via its existing + // lazy-init path. + ZenClient.registerPatches(); + PatchAgent.installPatchesAndRetransform(); + + LOGGER.info("DllBootstrap done. ZenClient will be constructed on the next Minecraft.tick."); + } catch (Throwable t) { + LOGGER.error("DllBootstrap.start failed", t); + } + } +} diff --git a/src/main/java/shit/zen/dll/GameLoaderBridge.java b/src/main/java/shit/zen/dll/GameLoaderBridge.java new file mode 100644 index 0000000..47ce89e --- /dev/null +++ b/src/main/java/shit/zen/dll/GameLoaderBridge.java @@ -0,0 +1,185 @@ +package shit.zen.dll; + +import asm.patchify.loader.PatchAgent; +import java.io.InputStream; +import java.lang.instrument.Instrumentation; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * One-shot bootstrap that is loaded by a throwaway {@code URLClassLoader} + * (parent = Forge GameClassLoader) and re-defines every class in zen.jar onto + * the GameClassLoader itself. + * + *

This is necessary because retransformed Minecraft classes contain + * {@code INVOKESTATIC} references to our patch handlers (e.g. + * {@code INVOKESTATIC shit/zen/patch/ConnectionPatch.transformReceive}). + * When the JVM resolves those references it uses the Minecraft class's + * defining loader (cpw.mods.cl.ModuleClassLoader). If our classes only live + * in a sibling URLClassLoader, resolution fails with + * {@code NoClassDefFoundError} / {@code VerifyError}.

+ * + *

Steps:

+ *
    + *
  1. Use the live {@link Instrumentation} (already populated by + * {@code instrument.dll}'s {@code Agent_OnAttach}) to {@code redefineModule} + * java.base/java.lang open to this module, so reflective access to + * {@code ClassLoader.defineClass} works without JVM args.
  2. + *
  3. Walk the jar. {@code .class} entries get re-defined on the game + * loader; everything else is extracted to a temp directory and exposed + * via the {@code openzen.resources} system property so + * {@code Bootstrap} and {@code ZenClient} can still find their + * resources (mapping.srg, cloud assets, webui static files).
  4. + *
  5. Hand off to {@link DllBootstrap#start(String)} loaded through the + * game loader.
  6. + *
+ */ +public final class GameLoaderBridge { + private static final Logger LOGGER = LogManager.getLogger("GameLoaderBridge"); + public static final String RESOURCES_PROP = "openzen.resources"; + + private GameLoaderBridge() { + } + + public static void load(String jarPath, ClassLoader gameLoader) throws Throwable { + LOGGER.info("GameLoaderBridge.load jar={} gameLoader={}", jarPath, gameLoader); + long t0 = System.nanoTime(); + + Instrumentation inst = PatchAgent.getInstrumentation(); + if (inst == null) { + throw new IllegalStateException("PatchAgent.getInstrumentation() returned null - " + + "did instrument.dll Agent_OnAttach run?"); + } + + openJavaBaseToSelf(inst); + + Method defineClass = ClassLoader.class.getDeclaredMethod( + "defineClass", String.class, byte[].class, int.class, int.class); + defineClass.setAccessible(true); + + Path resourceDir = Paths.get(System.getProperty("java.io.tmpdir"), + "openzen-resources-" + ProcessHandle.current().pid()); + Files.createDirectories(resourceDir); + + // Pass 1: read every entry. Defer .class defines until pass 2 so we can + // retry in dependency order (a patch class extending a zen base class + // can only be defined after its super has been defined). + LinkedHashMap pendingClasses = new LinkedHashMap<>(); + int resourceCount = 0; + + try (ZipFile zip = new ZipFile(jarPath)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory()) continue; + String name = entry.getName(); + if (name.startsWith("META-INF/")) continue; + + byte[] bytes; + try (InputStream is = zip.getInputStream(entry)) { + bytes = is.readAllBytes(); + } + + if (name.endsWith(".class")) { + String className = name.substring(0, name.length() - 6).replace('/', '.'); + // GameLoaderBridge itself is already live in this URLClassLoader. + if (className.equals(GameLoaderBridge.class.getName())) continue; + pendingClasses.put(className, bytes); + } else { + Path target = resourceDir.resolve(name); + Path parent = target.getParent(); + if (parent != null) Files.createDirectories(parent); + Files.write(target, bytes); + resourceCount++; + } + } + } + + int classCount = definePassUntilFixedPoint(defineClass, gameLoader, pendingClasses); + + if (!pendingClasses.isEmpty()) { + LOGGER.warn("{} class(es) could not be defined on gameLoader after fixed-point retry:", + pendingClasses.size()); + for (Map.Entry e : pendingClasses.entrySet()) { + try { + defineClass.invoke(gameLoader, e.getKey(), e.getValue(), 0, e.getValue().length); + } catch (Throwable t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + LOGGER.warn(" {} -> {}", e.getKey(), cause.toString()); + } + } + } + + System.setProperty(RESOURCES_PROP, resourceDir.toAbsolutePath().toString()); + + long ms = (System.nanoTime() - t0) / 1_000_000L; + LOGGER.info("Defined {} classes, extracted {} resources to {} ({} ms)", + classCount, resourceCount, resourceDir, ms); + + Class bootstrapCls = Class.forName("shit.zen.dll.DllBootstrap", true, gameLoader); + LOGGER.info("DllBootstrap loader (should be gameLoader): {}", bootstrapCls.getClassLoader()); + Method start = bootstrapCls.getMethod("start", String.class); + start.invoke(null, jarPath); + } + + /** + * Defines classes onto {@code gameLoader} in repeated passes. Each pass + * retries everything that previously failed; this terminates either when + * all entries are defined or when a full pass made no progress (genuinely + * missing dependency). The remaining failures stay in {@code pending} for + * the caller to log. + */ + private static int definePassUntilFixedPoint(Method defineClass, + ClassLoader gameLoader, + LinkedHashMap pending) { + int total = 0; + boolean progressed = true; + while (progressed && !pending.isEmpty()) { + progressed = false; + Iterator> it = pending.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + try { + defineClass.invoke(gameLoader, entry.getKey(), + entry.getValue(), 0, entry.getValue().length); + it.remove(); + progressed = true; + total++; + } catch (Throwable t) { + // Leave it in the pending map; another pass might succeed + // once dependencies are defined. The non-recoverable + // failures are logged by the caller. + } + } + } + return total; + } + + private static void openJavaBaseToSelf(Instrumentation inst) { + Module javaBase = ClassLoader.class.getModule(); + Module here = GameLoaderBridge.class.getModule(); + try { + inst.redefineModule( + javaBase, + Set.of(), + Map.of(), + Map.of("java.lang", Set.of(here)), + Set.of(), + Map.of()); + } catch (Throwable t) { + LOGGER.warn("redefineModule for java.base/java.lang failed (may be already open): {}", + t.toString()); + } + } +} diff --git a/src/main/java/shit/zen/network/webui/StaticFileHandler.java b/src/main/java/shit/zen/network/webui/StaticFileHandler.java index 6deb5bc..9230414 100644 --- a/src/main/java/shit/zen/network/webui/StaticFileHandler.java +++ b/src/main/java/shit/zen/network/webui/StaticFileHandler.java @@ -7,6 +7,7 @@ import java.io.OutputStream; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Locale; +import shit.zen.utils.misc.Assets; public class StaticFileHandler extends AbstractHttpHandler { private final String resourcePath; @@ -29,7 +30,7 @@ public class StaticFileHandler extends AbstractHttpHandler { relative += "index.html"; } String classpath = this.resourcePath + "/" + relative; - InputStream resource = StaticFileHandler.class.getResourceAsStream(classpath); + InputStream resource = Assets.open(classpath); if (resource == null) { return 404; } @@ -42,7 +43,7 @@ public class StaticFileHandler extends AbstractHttpHandler { @Override public void sendResponse(int status, OutputStream out, HttpExchange exchange) throws IOException { - InputStream resource = StaticFileHandler.class.getResourceAsStream(this.resourcePath + "/" + status + ".html"); + InputStream resource = Assets.open(this.resourcePath + "/" + status + ".html"); if (resource != null) { try (resource) { exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8"); diff --git a/src/main/java/shit/zen/patch/MinecraftPatch.java b/src/main/java/shit/zen/patch/MinecraftPatch.java index c4bda46..2e1db7b 100644 --- a/src/main/java/shit/zen/patch/MinecraftPatch.java +++ b/src/main/java/shit/zen/patch/MinecraftPatch.java @@ -34,6 +34,10 @@ public class MinecraftPatch { public static volatile boolean initialized = false; private static HitResult savedHitResult; + public static void markInitialized() { + initialized = true; + } + @Inject(method = "tick", desc = "()V") public static void onTick(Minecraft minecraft, CallbackInfo callbackInfo) throws Throwable { if (!initialized) { diff --git a/src/main/java/shit/zen/render/FontStore.java b/src/main/java/shit/zen/render/FontStore.java index e047a9e..6c7d4e9 100644 --- a/src/main/java/shit/zen/render/FontStore.java +++ b/src/main/java/shit/zen/render/FontStore.java @@ -3,6 +3,7 @@ package shit.zen.render; import java.awt.Font; import java.io.InputStream; import org.jetbrains.annotations.NotNull; +import shit.zen.utils.misc.Assets; public class FontStore { public static CustomFont OPENSANS_16 = loadFont(16.0f, "opensans.ttf"); @@ -25,7 +26,7 @@ public class FontStore { @NotNull public static CustomFont loadFont(float size, String name) { - try (InputStream stream = FontStore.class.getResourceAsStream("/assets/zen/fonts/" + name)) { + try (InputStream stream = Assets.open("/assets/zen/fonts/" + name)) { if (stream == null) { throw new java.io.IOException("Font not found: " + name); } diff --git a/src/main/java/shit/zen/render/Fonts.java b/src/main/java/shit/zen/render/Fonts.java index 7baa4a4..e2b70d2 100644 --- a/src/main/java/shit/zen/render/Fonts.java +++ b/src/main/java/shit/zen/render/Fonts.java @@ -4,6 +4,7 @@ import java.awt.Font; import java.io.InputStream; import java.util.HashMap; import java.util.Map; +import shit.zen.utils.misc.Assets; public final class Fonts { private static final Map fontRendererCache = new HashMap<>(); @@ -39,7 +40,7 @@ public final class Fonts { } private static Font loadAwtFont(String name) { - try (InputStream stream = Fonts.class.getResourceAsStream("/assets/zen/fonts/" + name)) { + try (InputStream stream = Assets.open("/assets/zen/fonts/" + name)) { if (stream == null) { return null; } diff --git a/src/main/java/shit/zen/utils/misc/Assets.java b/src/main/java/shit/zen/utils/misc/Assets.java new file mode 100644 index 0000000..7295ae9 --- /dev/null +++ b/src/main/java/shit/zen/utils/misc/Assets.java @@ -0,0 +1,45 @@ +package shit.zen.utils.misc; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Unified resource opener for the OpenZen jar. + * + *

Resolution order: + *

    + *
  1. {@code Assets.class.getResourceAsStream(classpath)} — works whenever + * the jar is on a class loader's search path (Forge mod path, dev + * {@code -javaagent}, etc.).
  2. + *
  3. The directory pointed to by the {@code openzen.resources} system + * property — populated by {@code GameLoaderBridge} on the DLL injection + * path because the jar is not on any class loader URL search there + * (classes are defined directly onto the game loader).
  4. + *
+ * + *

Always returns {@code null} (not throwing) when the resource is missing, + * matching the contract of {@link Class#getResourceAsStream(String)}.

+ */ +public final class Assets { + private Assets() { + } + + public static InputStream open(String classpath) { + InputStream cp = Assets.class.getResourceAsStream(classpath); + if (cp != null) return cp; + String dir = System.getProperty("openzen.resources"); + if (dir != null) { + String relative = classpath.startsWith("/") ? classpath.substring(1) : classpath; + File f = new File(dir, relative); + if (f.isFile()) { + try { + return new FileInputStream(f); + } catch (IOException ignored) { + } + } + } + return null; + } +} diff --git a/src/main/java/shit/zen/utils/misc/ReflectionUtil.java b/src/main/java/shit/zen/utils/misc/ReflectionUtil.java index a4eb708..6836838 100644 --- a/src/main/java/shit/zen/utils/misc/ReflectionUtil.java +++ b/src/main/java/shit/zen/utils/misc/ReflectionUtil.java @@ -195,11 +195,38 @@ public final class ReflectionUtil { } public static String getMappedFieldName(Class clazz, String fieldName) { - return fieldName; + return shit.zen.asm.Bootstrap.remapField(clazz.getName().replace('.', '/'), fieldName); } public static String getMappedMethodName(Class clazz, String methodName, String descriptor) { - return methodName; + return shit.zen.asm.Bootstrap.remapMethod(clazz.getName().replace('.', '/'), methodName, descriptor); + } + + /** + * Looks the field up by trying the supplied (mojmap) name first, then the + * SRG-remapped name. Walks the superclass chain because the field may be + * declared on an ancestor (e.g. {@code activeEffects} lives on + * {@code LivingEntity}, not on {@code LocalPlayer}). + */ + private static Field resolveField(Class clazz, String name) throws NoSuchFieldException { + NoSuchFieldException last = null; + for (Class c = clazz; c != null; c = c.getSuperclass()) { + String srg = shit.zen.asm.Bootstrap.remapField( + c.getName().replace('.', '/'), name); + try { + return c.getDeclaredField(srg); + } catch (NoSuchFieldException e) { + last = e; + } + if (!srg.equals(name)) { + try { + return c.getDeclaredField(name); + } catch (NoSuchFieldException e) { + last = e; + } + } + } + throw last != null ? last : new NoSuchFieldException(name); } private static long getFieldOffset(Class clazz, String name) { @@ -207,7 +234,7 @@ public final class ReflectionUtil { long offset = offsetCache.getLong(key); if (offset != 0L) return offset; try { - offset = unsafe.objectFieldOffset(clazz.getDeclaredField(name)); + offset = unsafe.objectFieldOffset(resolveField(clazz, name)); offsetCache.put(key, offset); return offset; } catch (NoSuchFieldException e) { @@ -220,7 +247,7 @@ public final class ReflectionUtil { ObjectLongPair pair = staticFieldCache.get(key); if (pair != null) return pair; try { - Field field = clazz.getDeclaredField(name); + Field field = resolveField(clazz, name); ObjectLongPair p = new ObjectLongImmutablePair<>(unsafe.staticFieldBase(field), unsafe.staticFieldOffset(field)); staticFieldCache.put(key, p); return p; @@ -235,9 +262,9 @@ public final class ReflectionUtil { ObjectLongPair pair = getStaticFieldPair(clazz, name); return unsafe.getObject(pair.first(), pair.secondLong()); } - // Fall back to a VarHandle + // Instance-field path: also walk up the superclass chain. try { - Field field = clazz.getDeclaredField(name); + Field field = resolveField(object.getClass(), name); field.setAccessible(true); return field.get(object); } catch (Exception e) {