当前提交
0da7d49fd6
465 file changed
+164690
No files matched your search
@@ -0,0 +1,49 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
# CMP0091 NEW lets us drive the MSVC runtime via MSVC_RUNTIME_LIBRARY
|
||||
# instead of patching CMAKE_CXX_FLAGS_*. CMake 3.15+ default, set
|
||||
# explicitly so the build stays predictable across CMake versions.
|
||||
cmake_policy(SET CMP0091 NEW)
|
||||
project(OpenZenNative LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# Statically link the MSVC C/C++ runtime into both the loader EXE and the
|
||||
# injected DLL. This matters most for the DLL: our manual mapper resolves
|
||||
# import addresses locally and assumes the imported DLL has the same base
|
||||
# in both processes - true for kernel32/user32/ntdll (KnownDLLs) but NOT
|
||||
# for vcruntime140/ucrtbase, so the mapped DLL would otherwise call CRT
|
||||
# functions through wild pointers and crash the target Java process. /MT
|
||||
# eliminates those imports entirely.
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
|
||||
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)
|
||||
# /MP enables cl.exe-level multi-process compilation: every translation
|
||||
# unit inside a single vcxproj compiles in parallel. Without it, MSBuild
|
||||
# only parallelises *across* projects (the -j N forwarded by
|
||||
# `cmake --build --parallel` controls that), so a single project with
|
||||
# a dozen .cpp files would still compile serially.
|
||||
add_compile_options(/W3 /permissive- /utf-8 /EHsc /MP)
|
||||
endif()
|
||||
|
||||
add_subdirectory(dll)
|
||||
add_subdirectory(loader)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
#include "resource.h"
|
||||
|
||||
IDR_ZEN_JAR RCDATA "zen.jar"
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define IDR_ZEN_JAR 101
|
||||
@@ -0,0 +1,177 @@
|
||||
#include "openzen.h"
|
||||
#include "generated_names.h" // OZ_BRIDGE_FQCN — generated by build.gradle ext.obfuscateJar
|
||||
|
||||
#include <cstring>
|
||||
|
||||
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);
|
||||
|
||||
// Anchor classes that are loaded early in every supported MC launch and
|
||||
// whose JVM internal name is stable across runtimes:
|
||||
// * net/minecraft/client/Minecraft - mojmap (ForgeGradle dev, modern
|
||||
// Forge obf->official remap)
|
||||
// * net/minecraft/client/class_310 - MCP/srg intermediate names
|
||||
// * net/minecraft/client/ClientBrandRetriever and
|
||||
// net/minecraft/client/main/Main - Mojang leaves these un-obfuscated
|
||||
// even in the obfuscated client jar; Main
|
||||
// is the spawn entry point.
|
||||
// Any of them is loaded by the same class loader that owns the game classes
|
||||
// (Forge GameClassLoader in production, the dev class loader in runClient).
|
||||
static const char* const kNeedles[] = {
|
||||
"Lnet/minecraft/client/Minecraft;",
|
||||
"Lnet/minecraft/client/class_310;",
|
||||
"Lnet/minecraft/client/ClientBrandRetriever;",
|
||||
"Lnet/minecraft/client/main/Main;",
|
||||
};
|
||||
|
||||
jobject game_loader = nullptr;
|
||||
const char* matched = nullptr;
|
||||
|
||||
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) {
|
||||
for (const char* needle : kNeedles) {
|
||||
if (std::strcmp(sig, needle) == 0) {
|
||||
matched = needle;
|
||||
game_loader = class_loader_of(env, classes[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
jvmti->Deallocate((unsigned char*)sig);
|
||||
}
|
||||
}
|
||||
|
||||
jvmti->Deallocate((unsigned char*)classes);
|
||||
|
||||
if (!game_loader) {
|
||||
// Give the loader EXE something actionable to show the user.
|
||||
std::string tried;
|
||||
for (const char* needle : kNeedles) {
|
||||
if (!tried.empty()) tried += ", ";
|
||||
tried += needle;
|
||||
}
|
||||
log::error("No game class loader found; tried: %s "
|
||||
"(the target process does not look like an MC/Forge JVM)", tried.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
log::info("Matched game class loader via %s", matched);
|
||||
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, "<init>", "(Ljava/lang/String;)V");
|
||||
if (!fileCtor) { check_and_clear(env, "GetMethodID File.<init>"); return nullptr; }
|
||||
jstring jarStr = env->NewString(
|
||||
reinterpret_cast<const jchar*>(jar_path.c_str()),
|
||||
static_cast<jsize>(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, "<init>",
|
||||
"([Ljava/net/URL;Ljava/lang/ClassLoader;)V");
|
||||
if (!urlclCtor) { check_and_clear(env, "GetMethodID URLClassLoader.<init>"); 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(OZ_BRIDGE_FQCN) (the build-time-obfuscated 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.
|
||||
//
|
||||
// The build renames every OpenZen class to an opaque generated name (see
|
||||
// build.gradle ext.obfuscateJar). It also emits generated_names.h with the
|
||||
// bridge's new FQCN as OZ_BRIDGE_FQCN, so this stays in lockstep with the
|
||||
// embedded jar without any hard-coded class name. The bridge's
|
||||
// load(String, ClassLoader) method name is preserved by the rename, so the
|
||||
// GetStaticMethodID(bridge_cls, "load", ...) lookup in main.cpp still works.
|
||||
jclass classLoaderCls = env->FindClass("java/lang/ClassLoader");
|
||||
jmethodID loadClass = env->GetMethodID(classLoaderCls, "loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;");
|
||||
jstring name = env->NewStringUTF(OZ_BRIDGE_FQCN);
|
||||
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<jclass>(loaded);
|
||||
}
|
||||
|
||||
} // namespace openzen::classes
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "openzen.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
|
||||
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<std::mutex> 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<std::mutex> 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;
|
||||
|
||||
// Per-bootstrap log file: %TEMP%\openzen-<pid>-<ticks>.log
|
||||
//
|
||||
// The old shared %TEMP%\openzen.log is unusable: the FIRST bootstrap keeps
|
||||
// its CREATE_ALWAYS handle open for the life of the process, so every later
|
||||
// injection (other processes, or a re-injection into the same JVM) hits
|
||||
// ERROR_SHARING_VIOLATION and logs nothing — failures become invisible.
|
||||
// Unique names make every injection independently diagnosable; stale files
|
||||
// from earlier attempts are removed best-effort (unremovable ones simply
|
||||
// stay until the owning process exits).
|
||||
const DWORD pid = GetCurrentProcessId();
|
||||
wchar_t base[MAX_PATH];
|
||||
std::swprintf(base, MAX_PATH, L"%sopenzen-%lu-", tmp, pid);
|
||||
|
||||
WIN32_FIND_DATAW fd;
|
||||
HANDLE find = FindFirstFileW((std::wstring(base) + L"*.log").c_str(), &fd);
|
||||
if (find != INVALID_HANDLE_VALUE) {
|
||||
do {
|
||||
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
|
||||
DeleteFileW((std::wstring(base) + fd.cFileName).c_str());
|
||||
} while (FindNextFileW(find, &fd));
|
||||
FindClose(find);
|
||||
}
|
||||
|
||||
wchar_t path[MAX_PATH];
|
||||
std::swprintf(path, MAX_PATH, L"%sopenzen-%lu-%llu.log", tmp, pid,
|
||||
(unsigned long long)GetTickCount64());
|
||||
|
||||
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
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "openzen.h"
|
||||
#include "resource.h"
|
||||
|
||||
namespace openzen::jar {
|
||||
|
||||
namespace {
|
||||
|
||||
// Walk the PE resource directory tree of an in-memory image to find an
|
||||
// RT_RCDATA entry by integer ID, without using FindResource / LoadResource.
|
||||
// We avoid the Win32 resource APIs here because the DLL may have been
|
||||
// manual-mapped: it is not in the loader's module list, so FindResource's
|
||||
// internal LdrFindResource_U call against `gSelfModule` cannot find a
|
||||
// matching LDR_DATA_TABLE_ENTRY and bails out.
|
||||
const void* find_rcdata(HMODULE module_base, WORD id, DWORD& out_size) {
|
||||
out_size = 0;
|
||||
auto base = reinterpret_cast<BYTE*>(module_base);
|
||||
auto dos = reinterpret_cast<PIMAGE_DOS_HEADER>(base);
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return nullptr;
|
||||
auto nt = reinterpret_cast<PIMAGE_NT_HEADERS>(base + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return nullptr;
|
||||
|
||||
const auto& res_dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE];
|
||||
if (res_dir.Size == 0) return nullptr;
|
||||
BYTE* root_base = base + res_dir.VirtualAddress;
|
||||
|
||||
auto find_id_child = [&](PIMAGE_RESOURCE_DIRECTORY dir, WORD wanted_id)
|
||||
-> PIMAGE_RESOURCE_DIRECTORY_ENTRY {
|
||||
auto entry = reinterpret_cast<PIMAGE_RESOURCE_DIRECTORY_ENTRY>(dir + 1);
|
||||
WORD total = dir->NumberOfNamedEntries + dir->NumberOfIdEntries;
|
||||
// ID entries follow the named entries.
|
||||
for (WORD i = dir->NumberOfNamedEntries; i < total; ++i) {
|
||||
auto e = entry + i;
|
||||
if (!e->NameIsString && e->Id == wanted_id) return e;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
// Level 1: resource type (RT_RCDATA = 10).
|
||||
auto root = reinterpret_cast<PIMAGE_RESOURCE_DIRECTORY>(root_base);
|
||||
auto type_entry = find_id_child(root, 10);
|
||||
if (!type_entry || !type_entry->DataIsDirectory) return nullptr;
|
||||
|
||||
// Level 2: resource name (our integer id).
|
||||
auto name_dir = reinterpret_cast<PIMAGE_RESOURCE_DIRECTORY>(
|
||||
root_base + type_entry->OffsetToDirectory);
|
||||
auto name_entry = find_id_child(name_dir, id);
|
||||
if (!name_entry || !name_entry->DataIsDirectory) return nullptr;
|
||||
|
||||
// Level 3: language. Take the first available.
|
||||
auto lang_dir = reinterpret_cast<PIMAGE_RESOURCE_DIRECTORY>(
|
||||
root_base + name_entry->OffsetToDirectory);
|
||||
WORD lang_count = lang_dir->NumberOfNamedEntries + lang_dir->NumberOfIdEntries;
|
||||
if (lang_count == 0) return nullptr;
|
||||
auto lang_entry = reinterpret_cast<PIMAGE_RESOURCE_DIRECTORY_ENTRY>(lang_dir + 1);
|
||||
auto data_entry = reinterpret_cast<PIMAGE_RESOURCE_DATA_ENTRY>(
|
||||
root_base + lang_entry->OffsetToData);
|
||||
|
||||
out_size = data_entry->Size;
|
||||
return base + data_entry->OffsetToData;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool extract_embedded(std::wstring& out_path) {
|
||||
DWORD size = 0;
|
||||
const void* data = find_rcdata(g_self_module, IDR_ZEN_JAR, size);
|
||||
if (!data || size == 0) {
|
||||
log::error("PE resource lookup for IDR_ZEN_JAR (RT_RCDATA) failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t tmp[MAX_PATH];
|
||||
if (GetTempPathW(MAX_PATH, tmp) == 0) {
|
||||
log::error("GetTempPath failed: %lu", GetLastError());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unique-per-attempt name: openzen-<pid>-<ticks>.jar
|
||||
//
|
||||
// A fixed openzen-<pid>.jar name breaks RE-injection into the same JVM:
|
||||
// once the JVM has consumed the jar (Java-side agent + the URLClassLoader
|
||||
// that loads GameLoaderBridge keep it open for the process lifetime), a
|
||||
// later CreateFileW(..., CREATE_ALWAYS) on the same path fails with
|
||||
// ERROR_SHARING_VIOLATION (32) and the whole bootstrap dies at
|
||||
// extraction. Unique names make every injection independent. Stale
|
||||
// artifacts from earlier attempts are removed best-effort.
|
||||
const DWORD pid = GetCurrentProcessId();
|
||||
wchar_t base[MAX_PATH];
|
||||
std::swprintf(base, MAX_PATH, L"%sopenzen-%lu-", tmp, pid);
|
||||
|
||||
WIN32_FIND_DATAW fd;
|
||||
HANDLE find = FindFirstFileW((std::wstring(base) + L"*.jar").c_str(), &fd);
|
||||
if (find != INVALID_HANDLE_VALUE) {
|
||||
do {
|
||||
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
|
||||
DeleteFileW((std::wstring(base) + fd.cFileName).c_str());
|
||||
} while (FindNextFileW(find, &fd));
|
||||
FindClose(find);
|
||||
}
|
||||
|
||||
wchar_t path[MAX_PATH];
|
||||
std::swprintf(path, MAX_PATH, L"%sopenzen-%lu-%llu.jar", tmp, pid,
|
||||
(unsigned long long)GetTickCount64());
|
||||
|
||||
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
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "openzen.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
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<JNI_GetCreatedJavaVMs_t>(
|
||||
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<Agent_OnAttach_t>(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<char> 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
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "openzen.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
extern "C" volatile LONG OpenZenBootstrapResult = -1;
|
||||
|
||||
namespace openzen {
|
||||
HMODULE g_self_module = nullptr;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::atomic<bool> g_already_attached{false};
|
||||
|
||||
void finish(LONG code) {
|
||||
OpenZenBootstrapResult = code;
|
||||
}
|
||||
|
||||
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) {
|
||||
finish(1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
JNIEnv* env = nullptr;
|
||||
JavaVMAttachArgs args{};
|
||||
args.version = JNI_VERSION_1_8;
|
||||
args.name = const_cast<char*>("OpenZen-Bootstrap");
|
||||
args.group = nullptr;
|
||||
if (vm->AttachCurrentThreadAsDaemon((void**)&env, &args) != JNI_OK || !env) {
|
||||
log::error("AttachCurrentThreadAsDaemon failed");
|
||||
finish(2);
|
||||
return 2;
|
||||
}
|
||||
log::info("Attached bootstrap thread to JavaVM");
|
||||
|
||||
std::wstring jar_path;
|
||||
if (!jar::extract_embedded(jar_path)) {
|
||||
vm->DetachCurrentThread();
|
||||
finish(3);
|
||||
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();
|
||||
finish(4);
|
||||
return 4;
|
||||
}
|
||||
|
||||
jclass bridge_cls = classes::load_dll_bootstrap(env, game_loader, jar_path);
|
||||
if (!bridge_cls) {
|
||||
env->DeleteLocalRef(game_loader);
|
||||
vm->DetachCurrentThread();
|
||||
finish(5);
|
||||
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();
|
||||
finish(6);
|
||||
return 6;
|
||||
}
|
||||
|
||||
jstring jar_jstr = env->NewString(
|
||||
reinterpret_cast<const jchar*>(jar_path.c_str()),
|
||||
static_cast<jsize>(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();
|
||||
env->DeleteLocalRef(jar_jstr);
|
||||
env->DeleteLocalRef(bridge_cls);
|
||||
env->DeleteLocalRef(game_loader);
|
||||
vm->DetachCurrentThread();
|
||||
finish(7);
|
||||
return 7;
|
||||
}
|
||||
log::info("GameLoaderBridge.load returned without exception");
|
||||
|
||||
env->DeleteLocalRef(jar_jstr);
|
||||
env->DeleteLocalRef(bridge_cls);
|
||||
env->DeleteLocalRef(game_loader);
|
||||
vm->DetachCurrentThread();
|
||||
finish(0);
|
||||
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) {
|
||||
finish(8);
|
||||
} else {
|
||||
CloseHandle(t);
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <jni.h>
|
||||
#include <jvmti.h>
|
||||
#include <string>
|
||||
|
||||
// Bootstrap verdict of the injected DLL, exported so the manual mapper in the
|
||||
// loader EXE can poll it through the export table and report what actually
|
||||
// happened instead of pretending DllMain == success.
|
||||
//
|
||||
// -1 still running / never started
|
||||
// 0 Java bootstrap finished (patches retransformed; client comes up on the
|
||||
// next Minecraft tick)
|
||||
// 1+ step-specific failure code (see loader/src/manual_map.cpp)
|
||||
extern "C" __declspec(dllexport) volatile LONG OpenZenBootstrapResult;
|
||||
|
||||
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
|
||||
@@ -0,0 +1,82 @@
|
||||
# OpenZenLoader — the GUI injector.
|
||||
#
|
||||
# Pure Win32 + GDI+: no third-party UI toolkit, no vcpkg. Everything links
|
||||
# against libraries that ship with the Windows SDK, so a fresh checkout
|
||||
# configures and builds in seconds (the slow part is compiling the injected
|
||||
# DLL, not this target).
|
||||
|
||||
# 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 $<TARGET_FILE:OpenZen> "${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/cli.h
|
||||
src/cli.cpp
|
||||
src/wgfx.h
|
||||
src/wgfx.cpp
|
||||
src/splash_win.h
|
||||
src/splash_win.cpp
|
||||
src/main_win.h
|
||||
src/main_win.cpp
|
||||
src/overlay_win.h
|
||||
src/overlay_win.cpp
|
||||
src/process_list.cpp
|
||||
src/injector.cpp
|
||||
src/manual_map.cpp
|
||||
src/embedded_dll.cpp
|
||||
src/window_title.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
|
||||
gdiplus # owner-drawn UI rendering
|
||||
dwmapi # DwmSetWindowAttribute for Win11 rounded corners
|
||||
psapi
|
||||
shlwapi
|
||||
shell32 # CommandLineToArgvW for the --nogui CLI mode
|
||||
user32
|
||||
gdi32
|
||||
)
|
||||
|
||||
# Build-time git revision passed in by Gradle (gradle.properties /
|
||||
# CI). Stamped into the window title so a built loader can be
|
||||
# traced back to its source commit.
|
||||
if(DEFINED OPENZEN_BUILD_REVISION AND NOT OPENZEN_BUILD_REVISION STREQUAL "")
|
||||
target_compile_definitions(OpenZenLoader PRIVATE
|
||||
OPENZEN_BUILD_REVISION="${OPENZEN_BUILD_REVISION}")
|
||||
endif()
|
||||
|
||||
set_target_properties(OpenZenLoader PROPERTIES
|
||||
OUTPUT_NAME "OpenZenLoader"
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
# requireAdministrator: lets the loader OpenProcess / VirtualAllocEx /
|
||||
# CreateRemoteThread against javaw.exe even when the launcher was
|
||||
# itself started elevated. UAC consent prompts once on launch.
|
||||
set_target_properties(OpenZenLoader PROPERTIES
|
||||
LINK_FLAGS "/SUBSYSTEM:WINDOWS /MANIFESTUAC:\"level='requireAdministrator' uiAccess='false'\""
|
||||
)
|
||||
endif()
|
||||
@@ -0,0 +1,3 @@
|
||||
#include "resource.h"
|
||||
|
||||
IDR_OPENZEN_DLL RCDATA "OpenZen.dll"
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define IDR_OPENZEN_DLL 201
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "cli.h"
|
||||
|
||||
#include "loader.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cli {
|
||||
|
||||
namespace {
|
||||
|
||||
// A /SUBSYSTEM:WINDOWS binary has no console of its own. When launched
|
||||
// from a terminal we attach to the parent's console so WriteConsoleW
|
||||
// output lands back in the caller's window. Best-effort: when the parent
|
||||
// has no console (double-click launch) output is simply dropped and the
|
||||
// exit code carries the result.
|
||||
void AttachParentConsole() {
|
||||
if (!AttachConsole(ATTACH_PARENT_PROCESS)) return;
|
||||
HANDLE conout = CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
if (conout == INVALID_HANDLE_VALUE) return;
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, conout);
|
||||
SetStdHandle(STD_ERROR_HANDLE, conout);
|
||||
}
|
||||
|
||||
void Write(const std::wstring& text) {
|
||||
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
if (!out || out == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteConsoleW(out, text.c_str(), static_cast<DWORD>(text.size()),
|
||||
&written, nullptr);
|
||||
}
|
||||
|
||||
bool ParsePid(const std::wstring& text, unsigned long& out) {
|
||||
if (text.empty() ||
|
||||
text.find_first_not_of(L"0123456789") != std::wstring::npos) {
|
||||
return false;
|
||||
}
|
||||
out = wcstoul(text.c_str(), nullptr, 10);
|
||||
return true;
|
||||
}
|
||||
|
||||
const wchar_t* kUsage =
|
||||
L"OpenZenLoader - command line mode\n"
|
||||
L"\n"
|
||||
L"Usage:\n"
|
||||
L" OpenZenLoader.exe <JavaPID> --nogui\n"
|
||||
L" Inject into the running Java process without showing the GUI.\n"
|
||||
L" OpenZenLoader.exe --help\n"
|
||||
L" Show this help.\n"
|
||||
L"\n"
|
||||
L"Exit codes: 0 = injected, 1 = injection failed, 2 = bad arguments\n";
|
||||
|
||||
} // namespace
|
||||
|
||||
int RunHeadless() {
|
||||
int argc = 0;
|
||||
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
|
||||
if (!argv) return -1;
|
||||
|
||||
bool headless = false;
|
||||
bool help = false;
|
||||
bool bad = false;
|
||||
bool havePid = false;
|
||||
unsigned long pid = 0;
|
||||
std::wstring badArg;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::wstring arg = argv[i];
|
||||
if (arg == L"--nogui" || arg == L"/nogui") {
|
||||
headless = true;
|
||||
} else if (arg == L"--help" || arg == L"-h" || arg == L"/?") {
|
||||
help = true;
|
||||
} else if (unsigned long value = 0; ParsePid(arg, value) && !havePid) {
|
||||
pid = value;
|
||||
havePid = true;
|
||||
} else {
|
||||
bad = true;
|
||||
badArg = arg;
|
||||
}
|
||||
}
|
||||
LocalFree(argv);
|
||||
|
||||
// No CLI request at all -> run the GUI as before.
|
||||
if (!headless && !help && !bad) return -1;
|
||||
|
||||
if (help && !bad) {
|
||||
AttachParentConsole();
|
||||
Write(kUsage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
AttachParentConsole();
|
||||
|
||||
if (bad) {
|
||||
Write(L"Unknown argument: " + badArg + L"\n\n" + kUsage);
|
||||
return 2;
|
||||
}
|
||||
if (help) {
|
||||
Write(kUsage);
|
||||
return 0;
|
||||
}
|
||||
if (!havePid) {
|
||||
Write(std::wstring(L"--nogui requires the PID of a running Java "
|
||||
L"process.\n\n") + kUsage);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Same target filter the GUI list uses: only javaw.exe / java.exe.
|
||||
bool found = false;
|
||||
std::wstring title;
|
||||
for (const loader::JavaProcess& p : loader::list_java_processes()) {
|
||||
if (p.pid == pid) {
|
||||
found = true;
|
||||
title = p.window_title.empty() ? p.image_name : p.window_title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
Write(L"Injection failed: PID " + std::to_wstring(pid) +
|
||||
L" is not a running Java (javaw.exe/java.exe) process\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Write(L"Injecting into PID " + std::to_wstring(pid) + L" (" + title +
|
||||
L")...\n");
|
||||
|
||||
const std::wstring err = loader::inject(pid);
|
||||
if (err.empty()) {
|
||||
Write(L"Injection complete\n");
|
||||
return 0;
|
||||
}
|
||||
Write(L"Injection failed: " + err + L"\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
namespace cli {
|
||||
|
||||
// Runs the loader headlessly when the command line asks for it
|
||||
// ("OpenZenLoader.exe <JavaPID> --nogui" or "--help"). Attaches to the
|
||||
// parent console for output and returns the process exit code to use.
|
||||
// Returns -1 when no CLI mode was requested, in which case the normal
|
||||
// GUI path should run.
|
||||
int RunHeadless();
|
||||
|
||||
} // namespace cli
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "loader.h"
|
||||
#include "resource.h"
|
||||
|
||||
namespace loader {
|
||||
|
||||
bool get_embedded_dll(const void*& out_data, size_t& out_size) {
|
||||
HMODULE self = GetModuleHandleW(nullptr);
|
||||
HRSRC info = FindResourceW(self, MAKEINTRESOURCEW(IDR_OPENZEN_DLL), RT_RCDATA);
|
||||
if (!info) return false;
|
||||
DWORD size = SizeofResource(self, info);
|
||||
if (size == 0) return false;
|
||||
HGLOBAL loaded = LoadResource(self, info);
|
||||
if (!loaded) return false;
|
||||
void* data = LockResource(loaded);
|
||||
if (!data) return false;
|
||||
out_data = data;
|
||||
out_size = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "loader.h"
|
||||
#include "manual_map.h"
|
||||
|
||||
namespace loader {
|
||||
|
||||
std::wstring inject(DWORD pid) {
|
||||
const void* dll_data = nullptr;
|
||||
size_t dll_size = 0;
|
||||
if (!get_embedded_dll(dll_data, dll_size)) {
|
||||
return L"Embedded OpenZen.dll resource not found in loader EXE";
|
||||
}
|
||||
return inject_in_memory(pid, dll_data, dll_size);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace loader {
|
||||
|
||||
struct JavaProcess {
|
||||
DWORD pid;
|
||||
std::wstring image_name;
|
||||
std::wstring command_line;
|
||||
std::wstring window_title;
|
||||
std::wstring window_class;
|
||||
};
|
||||
|
||||
struct WindowInfo {
|
||||
std::wstring title;
|
||||
std::wstring class_name;
|
||||
};
|
||||
|
||||
// Enumerate processes whose image is javaw.exe / java.exe.
|
||||
std::vector<JavaProcess> list_java_processes();
|
||||
|
||||
// Map the embedded OpenZen.dll directly into the target process and run its
|
||||
// DllMain via shellcode. The DLL bytes never touch disk. Returns an empty
|
||||
// string on success or a human-readable error message.
|
||||
std::wstring inject(DWORD pid);
|
||||
|
||||
// Return a pointer into the loader EXE's resource section that holds the
|
||||
// embedded OpenZen.dll along with its byte size. The pointer remains valid
|
||||
// for the lifetime of the loader process.
|
||||
bool get_embedded_dll(const void*& out_data, size_t& out_size);
|
||||
|
||||
// Walk top-level windows and return the title + class name of the most
|
||||
// informative window belonging to the given pid (longest title wins).
|
||||
// Returns empty strings if none found.
|
||||
WindowInfo window_info_for(DWORD pid);
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,242 @@
|
||||
//
|
||||
// main.cpp — Win32 entry point and window orchestration.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// GUI (no arguments): splash plays its ~1 s intro, the main window fades
|
||||
// in, and clicking a row's Inject opens the progress overlay which runs
|
||||
// the injection on a worker thread; on completion the loader fades out.
|
||||
//
|
||||
// Headless (command line): inject without any UI and exit. Progress is
|
||||
// printed to the parent console when present (exit code 0 = bootstrap
|
||||
// complete); failures pop a message box. Useful for launcher integrations
|
||||
// and scripted starts:
|
||||
// OpenZenLoader.exe 34028 --nogui inject into PID 34028, no UI
|
||||
// OpenZenLoader.exe 34028 same (--nogui implied by the pid)
|
||||
// OpenZenLoader.exe --nogui inject into every detected
|
||||
// Minecraft instance
|
||||
// OpenZenLoader.exe --help print usage, exit 0
|
||||
//
|
||||
// The GUI itself is pure Win32 + GDI+ (no Qt): the whole UI lives in wgfx /
|
||||
// splash / main / overlay and links only system libraries.
|
||||
//
|
||||
|
||||
#include "loader.h"
|
||||
#include "main_win.h"
|
||||
#include "overlay_win.h"
|
||||
#include "splash_win.h"
|
||||
#include "wgfx.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <gdiplus.h>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
const wchar_t* kUsage =
|
||||
L"OpenZenLoader - command line mode\n"
|
||||
L"\n"
|
||||
L"Usage:\n"
|
||||
L" OpenZenLoader.exe <JavaPID> [--nogui]\n"
|
||||
L" Inject OpenZen into the running Java process (must be a\n"
|
||||
L" javaw.exe/java.exe process). Prints progress; exit code 0 = ok.\n"
|
||||
L" OpenZenLoader.exe --nogui\n"
|
||||
L" Inject into every detected Minecraft instance.\n"
|
||||
L" OpenZenLoader.exe --help\n"
|
||||
L" Show this help (exit code 0).\n"
|
||||
L"\n"
|
||||
L"Exit codes: 0 = success, 1 = injection failed, 2 = bad arguments\n";
|
||||
|
||||
// A /SUBSYSTEM:WINDOWS binary has no console of its own. When launched from a
|
||||
// terminal we attach to the parent's console so WriteConsoleW output lands
|
||||
// back in the caller's window. Best-effort: when the parent has no console
|
||||
// (double-click launch) output is simply dropped and the exit code carries
|
||||
// the result.
|
||||
bool AttachParentConsole() {
|
||||
if (!AttachConsole(ATTACH_PARENT_PROCESS)) return false;
|
||||
HANDLE conout = CreateFileW(L"CONOUT$", GENERIC_WRITE, FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
if (conout == INVALID_HANDLE_VALUE) return false;
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, conout);
|
||||
SetStdHandle(STD_ERROR_HANDLE, conout);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Write(const std::wstring& text) {
|
||||
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
if (!out || out == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteConsoleW(out, text.c_str(), static_cast<DWORD>(text.size()),
|
||||
&written, nullptr);
|
||||
}
|
||||
|
||||
// Headless: inject one pid, report via exit code (+ message box on error).
|
||||
int RunHeadless(unsigned long pid) {
|
||||
Write(L"Injecting into PID " + std::to_wstring(pid) + L"...\n");
|
||||
std::wstring err = loader::inject(pid);
|
||||
if (err.empty()) {
|
||||
Write(L"Injection complete: OpenZen bootstrap finished; "
|
||||
L"the client should appear on the next tick.\n");
|
||||
ui::LogLine(L"headless inject ok, pid=" + std::to_wstring(pid));
|
||||
return 0;
|
||||
}
|
||||
Write(L"Injection failed: " + err + L"\n");
|
||||
ui::LogLine(L"headless inject FAILED, pid=" + std::to_wstring(pid) +
|
||||
L": " + err);
|
||||
MessageBoxW(nullptr,
|
||||
(L"Injection failed (PID " + std::to_wstring(pid) + L"):\n" +
|
||||
err)
|
||||
.c_str(),
|
||||
L"OpenZen Loader", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Headless: inject into every Minecraft instance we can find.
|
||||
int RunHeadlessAll() {
|
||||
auto procs = loader::list_java_processes();
|
||||
std::vector<unsigned long> targets;
|
||||
std::vector<std::wstring> labels;
|
||||
for (const auto& jp : procs) {
|
||||
if (!ui::LooksLikeMinecraft(jp)) continue;
|
||||
targets.push_back(jp.pid);
|
||||
labels.push_back(jp.window_title.empty() ? jp.window_class
|
||||
: jp.window_title);
|
||||
}
|
||||
if (targets.empty()) {
|
||||
ui::LogLine(L"headless inject: no Minecraft instance detected");
|
||||
Write(L"No Minecraft instances detected.\n");
|
||||
MessageBoxW(nullptr, L"No Minecraft instances detected.",
|
||||
L"OpenZen Loader", MB_OK | MB_ICONWARNING);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int failed = 0;
|
||||
for (size_t i = 0; i < targets.size(); ++i) {
|
||||
std::wstring err = loader::inject(targets[i]);
|
||||
if (err.empty()) {
|
||||
Write(L"Injected " + labels[i] + L" (PID " +
|
||||
std::to_wstring(targets[i]) + L"): bootstrap complete.\n");
|
||||
ui::LogLine(L"headless inject ok, pid=" +
|
||||
std::to_wstring(targets[i]));
|
||||
} else {
|
||||
++failed;
|
||||
Write(L"Inject FAILED for " + labels[i] + L" (PID " +
|
||||
std::to_wstring(targets[i]) + L"): " + err + L"\n");
|
||||
ui::LogLine(L"headless inject FAILED, pid=" +
|
||||
std::to_wstring(targets[i]) + L": " + err);
|
||||
MessageBoxW(nullptr,
|
||||
(L"Injection failed (" + labels[i] + L", PID " +
|
||||
std::to_wstring(targets[i]) + L"):\n" + err)
|
||||
.c_str(),
|
||||
L"OpenZen Loader", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
}
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int APIENTRY wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) {
|
||||
unsigned long cliPid = 0;
|
||||
bool nogui = false;
|
||||
bool help = false;
|
||||
bool bad = false;
|
||||
std::wstring badArg;
|
||||
for (int i = 1; i < __argc; ++i) {
|
||||
std::wstring a = __wargv[i];
|
||||
if (_wcsicmp(a.c_str(), L"--nogui") == 0 ||
|
||||
_wcsicmp(a.c_str(), L"/nogui") == 0) {
|
||||
nogui = true;
|
||||
} else if (_wcsicmp(a.c_str(), L"--help") == 0 ||
|
||||
_wcsicmp(a.c_str(), L"-h") == 0 ||
|
||||
_wcsicmp(a.c_str(), L"/?") == 0) {
|
||||
help = true;
|
||||
} else {
|
||||
wchar_t* end = nullptr;
|
||||
unsigned long v = wcstoul(a.c_str(), &end, 10);
|
||||
if (end && *end == L'\0' && v != 0) {
|
||||
cliPid = v;
|
||||
} else {
|
||||
bad = true;
|
||||
badArg = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool haveCli = help || bad || cliPid != 0 || nogui;
|
||||
if (haveCli) {
|
||||
// Don't run the message loop below: cli.cpp holds the console-attach
|
||||
// helpers too, but the GUI stays out of the headless path entirely.
|
||||
AttachParentConsole();
|
||||
if (help && !bad) {
|
||||
Write(kUsage);
|
||||
return 0;
|
||||
}
|
||||
if (bad) {
|
||||
Write(L"Unknown argument: " + badArg + L"\n" + kUsage);
|
||||
return 2;
|
||||
}
|
||||
if (cliPid != 0) {
|
||||
// Only ever inject into a Java process: a typo'd PID must not end
|
||||
// up mapping our DLL into notepad.exe.
|
||||
bool isJava = false;
|
||||
std::wstring image;
|
||||
for (const auto& p : loader::list_java_processes()) {
|
||||
if (p.pid == cliPid) {
|
||||
isJava = true;
|
||||
image = p.image_name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isJava) {
|
||||
Write(L"Injection failed: PID " + std::to_wstring(cliPid) +
|
||||
L" is not a running Java (javaw.exe/java.exe) process\n");
|
||||
return 1;
|
||||
}
|
||||
return RunHeadless(cliPid);
|
||||
}
|
||||
// --nogui without a PID: every Minecraft instance it can find.
|
||||
return RunHeadlessAll();
|
||||
}
|
||||
|
||||
ui::InitDpi();
|
||||
ui::LogLine(L"--- loader start (gui), scale=" +
|
||||
std::to_wstring(ui::g_scale));
|
||||
Gdiplus::GdiplusStartupInput gdiplusStartupInput;
|
||||
ULONG_PTR gdiplusToken = 0;
|
||||
Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, nullptr);
|
||||
int rc = 0;
|
||||
{
|
||||
ui::SplashWindow splash;
|
||||
ui::MainWindow main;
|
||||
|
||||
// Splash done -> reveal the main window with its entrance animation.
|
||||
splash.onFinished = [&main] { main.PlayEntrance(); };
|
||||
|
||||
// Row Inject click -> show the progress overlay and run the real
|
||||
// injection on the overlay's worker thread.
|
||||
std::unique_ptr<ui::OverlayWindow> overlay;
|
||||
main.onInjectRequested = [&](unsigned long pid,
|
||||
const std::wstring& title) {
|
||||
if (main.IsInjecting()) return;
|
||||
main.SetInjectionInFlight(true);
|
||||
overlay = std::make_unique<ui::OverlayWindow>();
|
||||
overlay->onCompleted = [&main](bool) { main.PlayExit(); };
|
||||
overlay->Show(main.hwnd(), pid, title);
|
||||
};
|
||||
|
||||
// Main window gone -> end the process.
|
||||
main.onClosed = [] { PostQuitMessage(0); };
|
||||
|
||||
splash.Show();
|
||||
|
||||
MSG msg{};
|
||||
while (GetMessageW(&msg, nullptr, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
rc = static_cast<int>(msg.wParam);
|
||||
}
|
||||
Gdiplus::GdiplusShutdown(gdiplusToken);
|
||||
return rc;
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
#include "main_win.h"
|
||||
|
||||
#include "loader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <windowsx.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
namespace ui {
|
||||
|
||||
bool LooksLikeMinecraft(const loader::JavaProcess& jp) {
|
||||
const std::wstring& t = jp.window_title;
|
||||
if (t.size() >= 9 && _wcsnicmp(t.c_str(), L"Minecraft", 9) == 0) return true;
|
||||
return _wcsicmp(jp.window_class.c_str(), L"GLFW30") == 0;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kCornerRadius = 12;
|
||||
constexpr int kBaseWidth = 760;
|
||||
constexpr int kBaseHeight = 500;
|
||||
constexpr int kSizeJitter = 10; // +/- px random size jitter per launch
|
||||
constexpr float kTitleBarH = 38.0f;
|
||||
constexpr float kRowH = 56.0f;
|
||||
constexpr float kBodyMarginX = 18.0f;
|
||||
constexpr float kBodyMarginY = 14.0f;
|
||||
constexpr float kBodySpacing = 10.0f;
|
||||
constexpr int kTimerAnim = 1; // 30 fps animation clock
|
||||
constexpr int kTimerPoll = 2; // instance list refresh
|
||||
|
||||
constexpr double kEntranceFade = 0.52;
|
||||
constexpr double kEntranceSlide = 0.56;
|
||||
constexpr double kEntranceDy = 18.0;
|
||||
constexpr double kExitFade = 0.28;
|
||||
constexpr double kRowEntrance = 0.28;
|
||||
constexpr double kRowHover = 0.14;
|
||||
constexpr double kPulsePeriod = 1.8;
|
||||
|
||||
float Scl(float v) { return static_cast<float>(v * g_scale); }
|
||||
|
||||
// Fills `title`/`class` for the Minecraft filter; returns false to skip.
|
||||
bool MinecraftFilter(const loader::JavaProcess& jp, std::wstring* outTitle) {
|
||||
if (!LooksLikeMinecraft(jp)) return false;
|
||||
if (!jp.window_title.empty()) {
|
||||
*outTitle = jp.window_title;
|
||||
} else {
|
||||
*outTitle = L"(starting up — " + jp.window_class + L")";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- creation / lifecycle ----
|
||||
|
||||
void MainWindow::Create() {
|
||||
baseW_ = static_cast<int>(kBaseWidth * g_scale);
|
||||
baseH_ = static_cast<int>(kBaseHeight * g_scale);
|
||||
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = &MainWindow::Thunk;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"OZLoaderMain";
|
||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
RegisterClassW(&wc);
|
||||
|
||||
std::wstring osTitle = RandomIdent(8, 16);
|
||||
hwnd_ = CreateWindowExW(WS_EX_LAYERED | WS_EX_APPWINDOW, wc.lpszClassName,
|
||||
osTitle.c_str(),
|
||||
WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX,
|
||||
0, 0, baseW_, baseH_, nullptr, nullptr,
|
||||
wc.hInstance, this);
|
||||
w_ = baseW_;
|
||||
h_ = baseH_;
|
||||
|
||||
RECT wa{};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0);
|
||||
baseX_ = (wa.left + wa.right - w_) / 2;
|
||||
baseY_ = (wa.top + wa.bottom - h_) / 2;
|
||||
SetWindowPos(hwnd_, nullptr, baseX_, baseY_, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOSIZE);
|
||||
|
||||
// Windows 11 rounded corners; harmless no-op on Windows 10.
|
||||
constexpr int DWMWA_WINDOW_CORNER_PREFERENCE_LOCAL = 33;
|
||||
constexpr int DWMWCP_ROUND_LOCAL = 2;
|
||||
int pref = DWMWCP_ROUND_LOCAL;
|
||||
DwmSetWindowAttribute(hwnd_, DWMWA_WINDOW_CORNER_PREFERENCE_LOCAL, &pref,
|
||||
sizeof(pref));
|
||||
|
||||
status_ = L"Watching for Minecraft processes…";
|
||||
Refresh();
|
||||
|
||||
SetTimer(hwnd_, kTimerAnim, 33, nullptr);
|
||||
SetTimer(hwnd_, kTimerPoll, 1000, nullptr);
|
||||
}
|
||||
|
||||
void MainWindow::PlayEntrance() {
|
||||
Create();
|
||||
ShowWindow(hwnd_, SW_SHOW);
|
||||
BringWindowToTop(hwnd_);
|
||||
SetForegroundWindow(hwnd_);
|
||||
tEntrance_ = Now();
|
||||
Tick();
|
||||
}
|
||||
|
||||
void MainWindow::PlayExit() {
|
||||
if (closing_) return;
|
||||
closing_ = true;
|
||||
KillTimer(hwnd_, kTimerPoll);
|
||||
if (injecting_) {
|
||||
// Injection in flight: tear down at once, no graceful fade (the
|
||||
// Qt version effectively aborted too when quit raced the worker).
|
||||
DestroyWindow(hwnd_);
|
||||
return;
|
||||
}
|
||||
tExit_ = Now();
|
||||
}
|
||||
|
||||
void MainWindow::Tick() {
|
||||
if (!hwnd_ || w_ < 1) return;
|
||||
Render();
|
||||
|
||||
BYTE alpha = 255;
|
||||
if (tExit_ >= 0.0) {
|
||||
double k = Since(tExit_, kExitFade);
|
||||
alpha = static_cast<BYTE>((1.0 - EaseInCubic(k)) * 255.0);
|
||||
if (k >= 1.0) {
|
||||
DestroyWindow(hwnd_);
|
||||
return;
|
||||
}
|
||||
} else if (tEntrance_ >= 0.0 && !entranceDone_) {
|
||||
double k = Since(tEntrance_, kEntranceFade);
|
||||
alpha = static_cast<BYTE>(EaseOutCubic(k) * 255.0);
|
||||
double ks = Since(tEntrance_, kEntranceSlide);
|
||||
int dy = static_cast<int>((1.0 - EaseOutCubic(ks)) * kEntranceDy * g_scale);
|
||||
SetWindowPos(hwnd_, nullptr, baseX_, baseY_ + dy, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOSIZE);
|
||||
if (ks >= 1.0 && k >= 1.0) entranceDone_ = true;
|
||||
}
|
||||
canvas_.Present(hwnd_, alpha);
|
||||
}
|
||||
|
||||
// ---- data ----
|
||||
|
||||
void MainWindow::Refresh() {
|
||||
auto procs = loader::list_java_processes();
|
||||
|
||||
std::vector<Row> next;
|
||||
next.reserve(procs.size());
|
||||
for (const auto& jp : procs) {
|
||||
Row r;
|
||||
if (!MinecraftFilter(jp, &r.title)) continue;
|
||||
r.pid = jp.pid;
|
||||
next.push_back(std::move(r));
|
||||
}
|
||||
|
||||
std::unordered_map<unsigned long, Row> old;
|
||||
old.reserve(rows_.size());
|
||||
for (auto& r : rows_) old.emplace(r.pid, std::move(r));
|
||||
|
||||
double now = Now();
|
||||
rows_.clear();
|
||||
rows_.reserve(next.size());
|
||||
for (auto& n : next) {
|
||||
auto it = old.find(n.pid);
|
||||
if (it != old.end()) {
|
||||
Row r = it->second;
|
||||
r.title = n.title; // titles can change during startup
|
||||
rows_.push_back(std::move(r));
|
||||
} else {
|
||||
n.born = now;
|
||||
rows_.push_back(std::move(n));
|
||||
}
|
||||
}
|
||||
|
||||
status_ = L"Watching " + std::to_wstring(rows_.size()) +
|
||||
L" Minecraft instance(s).";
|
||||
|
||||
float maxScroll = ContentHeight() - ComputeGeo(w_, h_).listBottom +
|
||||
ComputeGeo(w_, h_).listTop;
|
||||
if (scroll_ > maxScroll) scroll_ = std::max(0.0f, maxScroll);
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
|
||||
MainWindow::Geo MainWindow::ComputeGeo(int w, int h) const {
|
||||
Geo g{};
|
||||
g.titleBarH = Scl(kTitleBarH);
|
||||
g.bodyL = Scl(kBodyMarginX);
|
||||
g.bodyR = static_cast<float>(w) - Scl(kBodyMarginX);
|
||||
g.bodyR = (g.bodyL > g.bodyR) ? g.bodyL : g.bodyR;
|
||||
|
||||
Gdiplus::Bitmap bmp1(1, 1, PixelFormat32bppPARGB);
|
||||
Gdiplus::Graphics mg(&bmp1);
|
||||
|
||||
Gdiplus::Font* fTitle = Font(L"Segoe UI", Scl(18), true);
|
||||
Gdiplus::Font* fHint = Font(L"Segoe UI", Scl(12));
|
||||
Gdiplus::Font* fStatus = Font(L"Segoe UI", Scl(13));
|
||||
|
||||
g.titleY = g.titleBarH + Scl(kBodyMarginY);
|
||||
g.titleH = LineHeight(mg, fTitle);
|
||||
|
||||
g.hintY = g.titleY + g.titleH + Scl(kBodySpacing);
|
||||
Gdiplus::RectF hintBounds;
|
||||
const std::wstring hint =
|
||||
L"Click Inject on the instance you want to load OpenZen into. "
|
||||
L"List refreshes every second.";
|
||||
mg.MeasureString(hint.c_str(), static_cast<INT>(hint.size()), fHint,
|
||||
Gdiplus::RectF(g.bodyL, 0, g.bodyR - g.bodyL, 10000),
|
||||
&NearFormat(), &hintBounds);
|
||||
g.hintH = hintBounds.Height;
|
||||
|
||||
g.listTop = g.hintY + g.hintH + Scl(kBodySpacing);
|
||||
g.statusH = LineHeight(mg, fStatus) + Scl(2 * 7) + 2;
|
||||
g.statusTop = static_cast<float>(h) - Scl(kBodyMarginY) - g.statusH;
|
||||
g.listBottom = g.statusTop - Scl(kBodySpacing);
|
||||
if (g.listBottom < g.listTop) g.listBottom = g.listTop;
|
||||
|
||||
g.btnW = Scl(46);
|
||||
g.closeBtnX = static_cast<float>(w) - g.btnW;
|
||||
g.minBtnX = g.closeBtnX - g.btnW;
|
||||
|
||||
g.rowX0 = g.bodyL + Scl(2);
|
||||
g.rowX1 = g.bodyR - Scl(9);
|
||||
g.scrollX = g.bodyR - Scl(9);
|
||||
g.scrollW = Scl(8);
|
||||
g.scrollY0 = g.listTop + Scl(4);
|
||||
g.scrollY1 = g.listBottom - Scl(4);
|
||||
return g;
|
||||
}
|
||||
|
||||
float MainWindow::ContentHeight() const {
|
||||
return static_cast<float>(rows_.size()) * Scl(kRowH) + Scl(4);
|
||||
}
|
||||
|
||||
float MainWindow::RowHoverValue(size_t i, double now) const {
|
||||
const Row& r = rows_[i];
|
||||
double k = (now - r.hoverStart) / kRowHover;
|
||||
if (k < 0) k = 0;
|
||||
if (k > 1) k = 1;
|
||||
return static_cast<float>(r.hoverFrom +
|
||||
(static_cast<float>(r.hoverTarget) - r.hoverFrom) *
|
||||
EaseOutCubic(k));
|
||||
}
|
||||
|
||||
void MainWindow::LayoutRowRect(const Geo& g, size_t i, float* y0,
|
||||
float* y1) const {
|
||||
*y0 = g.listTop + Scl(2) + static_cast<float>(i) * Scl(kRowH) - scroll_;
|
||||
*y1 = *y0 + Scl(kRowH);
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
void MainWindow::Render() {
|
||||
if (!canvas_.Resize(w_, h_)) return;
|
||||
Gdiplus::Graphics& g = canvas_.g();
|
||||
canvas_.Clear();
|
||||
double now = Now();
|
||||
|
||||
float wf = static_cast<float>(w_);
|
||||
float hf = static_cast<float>(h_);
|
||||
Geo geo = ComputeGeo(w_, h_);
|
||||
|
||||
Gdiplus::RectF panelRect(0.5f, 0.5f, wf - 1.0f, hf - 1.0f);
|
||||
Gdiplus::GraphicsPath* panel =
|
||||
RoundedRectPath(panelRect, Scl(kCornerRadius));
|
||||
|
||||
// Rounded gradient panel.
|
||||
{
|
||||
Gdiplus::LinearGradientBrush bg(panelRect, Hex(0x1f2127), Hex(0x15171c),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&bg, panel);
|
||||
}
|
||||
|
||||
// Everything below is clipped to the rounded panel so the square title
|
||||
// bar corners never poke out of the rounded window shape.
|
||||
Gdiplus::GraphicsState st = g.Save();
|
||||
g.SetClip(panel);
|
||||
|
||||
// --- title bar ---
|
||||
{
|
||||
Gdiplus::RectF tbRect(0, 0, wf, geo.titleBarH);
|
||||
Gdiplus::LinearGradientBrush tb(tbRect, Hex(0x23252c), Hex(0x1b1d22),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillRectangle(&tb, tbRect);
|
||||
Gdiplus::Pen hairline(Rgba(255, 255, 255, 14), 1.0f);
|
||||
g.DrawLine(&hairline, 0.0f, geo.titleBarH, wf, geo.titleBarH);
|
||||
|
||||
// Pulsing scan-status dot: solid core + breathing halo.
|
||||
double pulse = 0.5 - 0.5 * cos(Now() * 2.0 * 3.14159265358979 /
|
||||
kPulsePeriod);
|
||||
float dcx = Scl(20.0f);
|
||||
float dcy = geo.titleBarH / 2.0f + Scl(1.5f);
|
||||
float halo = Scl(5.0f + 5.0f * static_cast<float>(pulse));
|
||||
int haloA = static_cast<int>(40 + 80 * pulse);
|
||||
Gdiplus::SolidBrush haloBrush(Rgba(110, 200, 140, haloA));
|
||||
g.FillEllipse(&haloBrush, dcx - halo, dcy - halo, halo * 2, halo * 2);
|
||||
float core = Scl(3.4f);
|
||||
Gdiplus::SolidBrush coreBrush(Rgba(120, 230, 150));
|
||||
g.FillEllipse(&coreBrush, dcx - core, dcy - core, core * 2, core * 2);
|
||||
|
||||
// Title (with build revision when provided by the build).
|
||||
std::wstring title = L"OpenZen Loader";
|
||||
#ifdef OPENZEN_BUILD_REVISION
|
||||
title = L"OpenZen Loader · build " + std::wstring(
|
||||
L"" OPENZEN_BUILD_REVISION).substr(0, 7);
|
||||
#endif
|
||||
Gdiplus::Font* fTb = Font(L"Segoe UI", Scl(12), true);
|
||||
float textY = geo.titleBarH / 2.0f - LineHeight(g, fTb) / 2.0f;
|
||||
DrawText(g, fTb, title, Scl(40.0f), textY, Hex(0xe7ecf5));
|
||||
|
||||
// Minimize / close buttons.
|
||||
Gdiplus::Font* fBtn = Font(L"Segoe UI", Scl(14));
|
||||
float btnH = geo.titleBarH;
|
||||
auto fillBtn = [&](float x, bool hover, bool pressed, bool isClose) {
|
||||
if (isClose) {
|
||||
if (pressed) {
|
||||
Gdiplus::SolidBrush b(Hex(0x8a2920));
|
||||
g.FillRectangle(&b, x, 0.0f, geo.btnW, btnH);
|
||||
} else if (hover) {
|
||||
Gdiplus::SolidBrush b(Hex(0xc0392b));
|
||||
g.FillRectangle(&b, x, 0.0f, geo.btnW, btnH);
|
||||
}
|
||||
} else if (pressed) {
|
||||
Gdiplus::SolidBrush b(Rgba(255, 255, 255, 28));
|
||||
g.FillRectangle(&b, x, 0.0f, geo.btnW, btnH);
|
||||
} else if (hover) {
|
||||
Gdiplus::SolidBrush b(Rgba(255, 255, 255, 18));
|
||||
g.FillRectangle(&b, x, 0.0f, geo.btnW, btnH);
|
||||
}
|
||||
};
|
||||
{
|
||||
fillBtn(geo.minBtnX, hoverMin_, pressed_ == 1, false);
|
||||
fillBtn(geo.closeBtnX, hoverClose_, pressed_ == 2, true);
|
||||
float cy = btnH / 2.0f;
|
||||
// en-dash glyph
|
||||
Gdiplus::Pen dash(hoverMin_ ? Gdiplus::Color(255, 255, 255)
|
||||
: Hex(0xaab1bf),
|
||||
Scl(1.2f));
|
||||
g.DrawLine(&dash, geo.minBtnX + Scl(16), cy, geo.minBtnX + Scl(30), cy);
|
||||
// X glyph (two strokes)
|
||||
Gdiplus::Pen xpen(hoverClose_ ? Gdiplus::Color(255, 255, 255)
|
||||
: Hex(0xaab1bf),
|
||||
Scl(1.2f));
|
||||
float cxm = (geo.closeBtnX + wf) / 2.0f;
|
||||
float r = Scl(6.0f);
|
||||
g.DrawLine(&xpen, cxm - r, cy - r, cxm + r, cy + r);
|
||||
g.DrawLine(&xpen, cxm - r, cy + r, cxm + r, cy - r);
|
||||
}
|
||||
}
|
||||
|
||||
// --- body ---
|
||||
{
|
||||
Gdiplus::Font* fTitle = Font(L"Segoe UI", Scl(18), true);
|
||||
DrawText(g, fTitle, L"Minecraft Instances", geo.bodyL + Scl(2),
|
||||
geo.titleY + Scl(2), Gdiplus::Color(255, 255, 255));
|
||||
|
||||
Gdiplus::Font* fHint = Font(L"Segoe UI", Scl(12));
|
||||
const std::wstring hint =
|
||||
L"Click Inject on the instance you want to load OpenZen into. "
|
||||
L"List refreshes every second.";
|
||||
DrawText(g, fHint, hint, geo.bodyL + Scl(2), geo.hintY, Hex(0x8a8e98),
|
||||
geo.bodyR - geo.bodyL);
|
||||
}
|
||||
|
||||
// --- instance list ---
|
||||
{
|
||||
g.SetClip(Gdiplus::RectF(geo.bodyL, geo.listTop,
|
||||
geo.bodyR - geo.bodyL,
|
||||
geo.listBottom - geo.listTop));
|
||||
|
||||
if (rows_.empty()) {
|
||||
Gdiplus::Font* fEmpty = Font(L"Segoe UI", Scl(12), false, true);
|
||||
float lh = LineHeight(g, fEmpty);
|
||||
float cy = (geo.listTop + geo.listBottom) / 2.0f;
|
||||
DrawTextCentered(g, fEmpty, L"No Minecraft instances detected.",
|
||||
(geo.rowX0 + geo.rowX1) / 2.0f, cy - lh - Scl(6),
|
||||
Hex(0x6a6f7a));
|
||||
DrawTextCentered(g, fEmpty,
|
||||
L"Start the game and it will show up here.",
|
||||
(geo.rowX0 + geo.rowX1) / 2.0f, cy + Scl(6),
|
||||
Hex(0x6a6f7a));
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < rows_.size(); ++i) {
|
||||
float y0, y1;
|
||||
LayoutRowRect(geo, i, &y0, &y1);
|
||||
if (y1 < geo.listTop || y0 > geo.listBottom) continue;
|
||||
|
||||
double e = Since(rows_[i].born, kRowEntrance);
|
||||
float hov = RowHoverValue(i, now);
|
||||
float dy = (1.0f - static_cast<float>(e)) * Scl(8.0f);
|
||||
float alpha = static_cast<float>(e);
|
||||
|
||||
Gdiplus::RectF rect(geo.rowX0 + Scl(4), y0 + Scl(4) + dy,
|
||||
geo.rowX1 - geo.rowX0 - Scl(8),
|
||||
Scl(kRowH) - Scl(8));
|
||||
Gdiplus::GraphicsPath* rp = RoundedRectPath(rect, Scl(9));
|
||||
|
||||
// Base fill (dimmest), entrance alpha applied.
|
||||
{
|
||||
Gdiplus::LinearGradientBrush base(
|
||||
rect,
|
||||
Rgba(38, 41, 48, static_cast<int>(255 * alpha)),
|
||||
Rgba(30, 32, 38, static_cast<int>(255 * alpha)),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&base, rp);
|
||||
}
|
||||
if (hov > 0.001f) {
|
||||
Gdiplus::LinearGradientBrush hover(
|
||||
rect,
|
||||
Rgba(74, 131, 224,
|
||||
static_cast<int>(38 * hov * alpha)),
|
||||
Rgba(50, 96, 189,
|
||||
static_cast<int>(22 * hov * alpha)),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&hover, rp);
|
||||
}
|
||||
|
||||
// Left accent stripe, brightens with hover.
|
||||
{
|
||||
Gdiplus::RectF stripe(rect.X, rect.Y, Scl(3), rect.Height);
|
||||
Gdiplus::GraphicsPath* sp = RoundedRectPath(stripe, Scl(2));
|
||||
int sa = static_cast<int>((60 + 160 * hov) *
|
||||
(alpha < 0.999f ? alpha : 1.0f));
|
||||
Gdiplus::SolidBrush sb(Rgba(85, 135, 235, sa));
|
||||
g.FillPath(&sb, sp);
|
||||
delete sp;
|
||||
}
|
||||
|
||||
// Outline brightens with hover.
|
||||
{
|
||||
Gdiplus::Pen border(Rgba(255, 255, 255,
|
||||
static_cast<int>((22 + 36 * hov) * alpha)),
|
||||
1.0f);
|
||||
g.DrawPath(&border, rp);
|
||||
}
|
||||
delete rp;
|
||||
|
||||
// --- row content ---
|
||||
Gdiplus::Font* fPid = Font(L"Consolas", Scl(12), true);
|
||||
Gdiplus::Font* fRowTitle = Font(L"Segoe UI", Scl(13));
|
||||
Gdiplus::Font* fInject = Font(L"Segoe UI", Scl(12), true);
|
||||
|
||||
float pidX = geo.rowX0 + Scl(14);
|
||||
float cy = (y0 + y1) / 2.0f;
|
||||
DrawText(g, fPid, std::to_wstring(rows_[i].pid), pidX,
|
||||
cy - LineHeight(g, fPid) / 2.0f, Hex(0x9aa3b2));
|
||||
|
||||
float titleX = pidX + Scl(72 + 12);
|
||||
DrawText(g, fRowTitle, rows_[i].title, titleX,
|
||||
cy - LineHeight(g, fRowTitle) / 2.0f, Hex(0xe7eaf2),
|
||||
geo.rowX1 - Scl(12) - Scl(80) - titleX);
|
||||
|
||||
// Inject button (gradient, rounded 6, h 30).
|
||||
float btnH = Scl(30);
|
||||
std::wstring label = L"Inject";
|
||||
float btnW = TextWidth(g, fInject, label) + Scl(2 * 18);
|
||||
float btnX = geo.rowX1 - Scl(12) - btnW;
|
||||
float btnY = cy - btnH / 2.0f;
|
||||
Gdiplus::RectF btnRect(btnX, btnY, btnW, btnH);
|
||||
Gdiplus::GraphicsPath* bp = RoundedRectPath(btnRect, Scl(6));
|
||||
bool enabled = !injecting_;
|
||||
bool btnHover = (hoverRow_ == static_cast<int>(i));
|
||||
bool btnPressed = (pressed_ == 3 &&
|
||||
pressedPid_ == rows_[i].pid);
|
||||
if (!enabled) {
|
||||
Gdiplus::SolidBrush b(Hex(0x2c2f37));
|
||||
g.FillPath(&b, bp);
|
||||
Gdiplus::Pen p(Hex(0x2c2f37), 1.0f);
|
||||
g.DrawPath(&p, bp);
|
||||
} else if (btnPressed) {
|
||||
Gdiplus::LinearGradientBrush b(btnRect, Hex(0x2c5db0),
|
||||
Hex(0x1e468f),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&b, bp);
|
||||
Gdiplus::Pen p(Rgba(255, 255, 255, 22), 1.0f);
|
||||
g.DrawPath(&p, bp);
|
||||
} else if (btnHover) {
|
||||
Gdiplus::LinearGradientBrush b(btnRect, Hex(0x5a93f0),
|
||||
Hex(0x4275d8),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&b, bp);
|
||||
Gdiplus::Pen p(Rgba(255, 255, 255, 60), 1.0f);
|
||||
g.DrawPath(&p, bp);
|
||||
} else {
|
||||
Gdiplus::LinearGradientBrush b(btnRect, Hex(0x4a83e0),
|
||||
Hex(0x3260bd),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&b, bp);
|
||||
Gdiplus::Pen p(Rgba(255, 255, 255, 22), 1.0f);
|
||||
g.DrawPath(&p, bp);
|
||||
}
|
||||
Gdiplus::Color labelCol =
|
||||
enabled ? Gdiplus::Color(255, 255, 255) : Hex(0x6a6f7a);
|
||||
Gdiplus::SolidBrush lb(labelCol);
|
||||
{
|
||||
Gdiplus::StringFormat sf(&NearFormat());
|
||||
sf.SetAlignment(Gdiplus::StringAlignmentCenter);
|
||||
sf.SetLineAlignment(Gdiplus::StringAlignmentCenter);
|
||||
g.DrawString(label.c_str(), static_cast<INT>(label.size()),
|
||||
fInject, btnRect, &sf, &lb);
|
||||
}
|
||||
delete bp;
|
||||
}
|
||||
g.ResetClip();
|
||||
}
|
||||
|
||||
// --- scrollbar ---
|
||||
{
|
||||
float contentH = ContentHeight();
|
||||
float viewH = geo.listBottom - geo.listTop;
|
||||
if (contentH > viewH + 0.5f) {
|
||||
float trackH = geo.scrollY1 - geo.scrollY0;
|
||||
float handleH = trackH * viewH / contentH;
|
||||
if (handleH < Scl(28)) handleH = Scl(28);
|
||||
float maxScroll = contentH - viewH;
|
||||
float handleY = geo.scrollY0 +
|
||||
(trackH - handleH) * (scroll_ / maxScroll);
|
||||
Gdiplus::RectF handle(geo.scrollX + Scl(1), handleY, Scl(8),
|
||||
handleH);
|
||||
Gdiplus::GraphicsPath* hp = RoundedRectPath(handle, Scl(4));
|
||||
Gdiplus::SolidBrush hb(hoverRow_ == -2 || scrollDrag_
|
||||
? Hex(0x4a4e58)
|
||||
: Hex(0x3a3d45));
|
||||
g.FillPath(&hb, hp);
|
||||
delete hp;
|
||||
}
|
||||
}
|
||||
|
||||
// --- status strip ---
|
||||
{
|
||||
Gdiplus::RectF stRect(geo.bodyL, geo.statusTop, geo.bodyR - geo.bodyL,
|
||||
geo.statusH);
|
||||
Gdiplus::GraphicsPath* sp = RoundedRectPath(stRect, Scl(7));
|
||||
Gdiplus::SolidBrush bg(Rgba(35, 37, 43, 200));
|
||||
g.FillPath(&bg, sp);
|
||||
Gdiplus::Pen border(Hex(0x2c2e35), 1.0f);
|
||||
g.DrawPath(&border, sp);
|
||||
delete sp;
|
||||
|
||||
Gdiplus::Font* fStatus = Font(L"Segoe UI", Scl(13));
|
||||
DrawText(g, fStatus, status_, geo.bodyL + Scl(11),
|
||||
geo.statusTop + Scl(7), Hex(0xc2c6cf),
|
||||
stRect.Width - Scl(2 * 11));
|
||||
}
|
||||
|
||||
g.Restore(st);
|
||||
|
||||
// Crisp hairline border above everything else.
|
||||
{
|
||||
Gdiplus::Pen border(Rgba(255, 255, 255, 26), 1.0f);
|
||||
g.DrawPath(&border, panel);
|
||||
}
|
||||
delete panel;
|
||||
}
|
||||
|
||||
// ---- interaction ----
|
||||
|
||||
namespace {
|
||||
bool InRect(float x, float y, float rx, float ry, float rw, float rh) {
|
||||
return x >= rx && x < rx + rw && y >= ry && y < ry + rh;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
LRESULT CALLBACK MainWindow::Thunk(HWND h, UINT m, WPARAM wp, LPARAM lp) {
|
||||
if (m == WM_NCCREATE) {
|
||||
auto* self = reinterpret_cast<MainWindow*>(
|
||||
reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);
|
||||
SetWindowLongPtrW(h, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||||
// Must be set before the first Handle() call: WM_NCCREATE itself is
|
||||
// routed to Handle() -> DefWindowProcW(hwnd_, ...), and a NULL hwnd_
|
||||
// there makes WM_NCCREATE return FALSE, which aborts window creation
|
||||
// outright (CreateWindowExW fails with ERROR_INVALID_WINDOW_HANDLE).
|
||||
self->hwnd_ = h;
|
||||
}
|
||||
auto* self =
|
||||
reinterpret_cast<MainWindow*>(GetWindowLongPtrW(h, GWLP_USERDATA));
|
||||
return self ? self->Handle(m, wp, lp) : DefWindowProcW(h, m, wp, lp);
|
||||
}
|
||||
|
||||
LRESULT MainWindow::Handle(UINT m, WPARAM wp, LPARAM lp) {
|
||||
switch (m) {
|
||||
case WM_TIMER: {
|
||||
if (wp == kTimerAnim) Tick();
|
||||
else if (wp == kTimerPoll && !injecting_) Refresh();
|
||||
return 0;
|
||||
}
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps;
|
||||
BeginPaint(hwnd_, &ps);
|
||||
EndPaint(hwnd_, &ps);
|
||||
if (tEntrance_ >= 0.0) Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
case WM_GETMINMAXINFO: {
|
||||
auto* mmi = reinterpret_cast<MINMAXINFO*>(lp);
|
||||
mmi->ptMinTrackSize.x = static_cast<LONG>((kBaseWidth - kSizeJitter) * g_scale);
|
||||
mmi->ptMinTrackSize.y = static_cast<LONG>((kBaseHeight - kSizeJitter) * g_scale);
|
||||
return 0;
|
||||
}
|
||||
case WM_SIZE: {
|
||||
int cw = static_cast<int>(LOWORD(lp));
|
||||
int ch = static_cast<int>(HIWORD(lp));
|
||||
if (wp != SIZE_MINIMIZED && cw > 0 && ch > 0) {
|
||||
w_ = cw;
|
||||
h_ = ch;
|
||||
float maxScroll =
|
||||
ContentHeight() - ComputeGeo(w_, h_).listBottom +
|
||||
ComputeGeo(w_, h_).listTop;
|
||||
if (scroll_ > maxScroll) scroll_ = std::max(0.0f, maxScroll);
|
||||
if (tEntrance_ < 0.0 || entranceDone_) Tick();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSEMOVE: {
|
||||
float x = static_cast<float>(GET_X_LPARAM(lp));
|
||||
float y = static_cast<float>(GET_Y_LPARAM(lp));
|
||||
Geo geo = ComputeGeo(w_, h_);
|
||||
|
||||
// Ask for a WM_MOUSELEAVE so row/button hover state clears when
|
||||
// the cursor exits the window.
|
||||
{
|
||||
TRACKMOUSEEVENT tme{sizeof(tme), TME_LEAVE, hwnd_, 0};
|
||||
TrackMouseEvent(&tme);
|
||||
}
|
||||
|
||||
if (scrollDrag_) {
|
||||
float trackH = geo.scrollY1 - geo.scrollY0;
|
||||
float contentH = ContentHeight();
|
||||
float viewH = geo.listBottom - geo.listTop;
|
||||
float handleH = trackH * viewH / contentH;
|
||||
if (handleH < Scl(28)) handleH = Scl(28);
|
||||
float rel = y - geo.scrollY0 - scrollGrab_;
|
||||
float maxScroll = contentH - viewH;
|
||||
float t = (trackH - handleH) > 0 ? rel / (trackH - handleH) : 0;
|
||||
scroll_ = t * maxScroll;
|
||||
if (scroll_ < 0) scroll_ = 0;
|
||||
if (scroll_ > maxScroll) scroll_ = maxScroll;
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool newMin = InRect(x, y, geo.minBtnX, 0, geo.btnW, geo.titleBarH);
|
||||
bool newClose =
|
||||
InRect(x, y, geo.closeBtnX, 0, geo.btnW, geo.titleBarH);
|
||||
int newHoverRow = -1;
|
||||
if (!newMin && !newClose && y >= geo.listTop &&
|
||||
y <= geo.listBottom && x >= geo.rowX0 && x <= geo.rowX1) {
|
||||
float rel = y - geo.listTop - Scl(2) + scroll_;
|
||||
int idx = static_cast<int>(rel / Scl(kRowH));
|
||||
if (idx >= 0 && idx < static_cast<int>(rows_.size()))
|
||||
newHoverRow = idx;
|
||||
}
|
||||
|
||||
if (newHoverRow != hoverRow_) {
|
||||
double now = Now();
|
||||
if (hoverRow_ >= 0 &&
|
||||
hoverRow_ < static_cast<int>(rows_.size())) {
|
||||
Row& r = rows_[hoverRow_];
|
||||
r.hoverFrom = RowHoverValue(hoverRow_, now);
|
||||
r.hoverStart = now;
|
||||
r.hoverTarget = false;
|
||||
}
|
||||
if (newHoverRow >= 0 &&
|
||||
newHoverRow < static_cast<int>(rows_.size())) {
|
||||
Row& r = rows_[newHoverRow];
|
||||
r.hoverFrom = RowHoverValue(newHoverRow, now);
|
||||
r.hoverStart = now;
|
||||
r.hoverTarget = true;
|
||||
}
|
||||
hoverRow_ = newHoverRow;
|
||||
}
|
||||
hoverMin_ = newMin;
|
||||
hoverClose_ = newClose;
|
||||
|
||||
// Hand cursor over interactive elements.
|
||||
bool overBtn = hoverMin_ || hoverClose_;
|
||||
bool overInject = false;
|
||||
if (hoverRow_ >= 0) {
|
||||
float y0, y1;
|
||||
LayoutRowRect(geo, hoverRow_, &y0, &y1);
|
||||
float btnH = Scl(30);
|
||||
std::wstring label = L"Inject";
|
||||
Gdiplus::Bitmap bmp1(1, 1, PixelFormat32bppPARGB);
|
||||
Gdiplus::Graphics mg(&bmp1);
|
||||
float btnW = TextWidth(mg, Font(L"Segoe UI", Scl(12), true),
|
||||
label) + Scl(2 * 18);
|
||||
float btnX = geo.rowX1 - Scl(12) - btnW;
|
||||
overInject = InRect(x, y, btnX, (y0 + y1) / 2.0f - btnH / 2.0f,
|
||||
btnW, btnH);
|
||||
}
|
||||
SetCursor(LoadCursorW(nullptr,
|
||||
(overBtn || overInject) ? IDC_HAND
|
||||
: IDC_ARROW));
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSELEAVE: {
|
||||
hoverMin_ = hoverClose_ = false;
|
||||
if (hoverRow_ >= 0 &&
|
||||
hoverRow_ < static_cast<int>(rows_.size())) {
|
||||
Row& r = rows_[hoverRow_];
|
||||
r.hoverFrom = RowHoverValue(hoverRow_, Now());
|
||||
r.hoverStart = Now();
|
||||
r.hoverTarget = false;
|
||||
}
|
||||
hoverRow_ = -1;
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_LBUTTONDOWN: {
|
||||
float x = static_cast<float>(GET_X_LPARAM(lp));
|
||||
float y = static_cast<float>(GET_Y_LPARAM(lp));
|
||||
Geo geo = ComputeGeo(w_, h_);
|
||||
pressed_ = 0;
|
||||
if (InRect(x, y, geo.minBtnX, 0, geo.btnW, geo.titleBarH)) {
|
||||
pressed_ = 1;
|
||||
} else if (InRect(x, y, geo.closeBtnX, 0, geo.btnW,
|
||||
geo.titleBarH)) {
|
||||
pressed_ = 2;
|
||||
} else if (hoverRow_ >= 0 && hoverRow_ < (int)rows_.size()) {
|
||||
float y0, y1;
|
||||
LayoutRowRect(geo, hoverRow_, &y0, &y1);
|
||||
float btnH = Scl(30);
|
||||
Gdiplus::Bitmap bmp1(1, 1, PixelFormat32bppPARGB);
|
||||
Gdiplus::Graphics mg(&bmp1);
|
||||
float btnW = TextWidth(mg, Font(L"Segoe UI", Scl(12), true),
|
||||
L"Inject") + Scl(2 * 18);
|
||||
float btnX = geo.rowX1 - Scl(12) - btnW;
|
||||
if (InRect(x, y, btnX, (y0 + y1) / 2.0f - btnH / 2.0f, btnW,
|
||||
btnH)) {
|
||||
pressed_ = 3;
|
||||
pressedPid_ = rows_[hoverRow_].pid;
|
||||
}
|
||||
}
|
||||
// Scrollbar drag start.
|
||||
if (pressed_ == 0) {
|
||||
float contentH = ContentHeight();
|
||||
float viewH = geo.listBottom - geo.listTop;
|
||||
if (contentH > viewH + 0.5f && InRect(x, y, geo.scrollX,
|
||||
geo.scrollY0,
|
||||
Scl(9),
|
||||
geo.scrollY1 -
|
||||
geo.scrollY0)) {
|
||||
float trackH = geo.scrollY1 - geo.scrollY0;
|
||||
float handleH = trackH * viewH / contentH;
|
||||
if (handleH < Scl(28)) handleH = Scl(28);
|
||||
float maxScroll = contentH - viewH;
|
||||
float handleY =
|
||||
geo.scrollY0 +
|
||||
(trackH - handleH) * (scroll_ / maxScroll);
|
||||
if (y >= handleY && y <= handleY + handleH) {
|
||||
scrollDrag_ = true;
|
||||
scrollGrab_ = y - handleY;
|
||||
SetCapture(hwnd_);
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
SetCapture(hwnd_);
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_LBUTTONUP: {
|
||||
if (scrollDrag_) {
|
||||
scrollDrag_ = false;
|
||||
if (GetCapture() == hwnd_) ReleaseCapture();
|
||||
}
|
||||
float x = static_cast<float>(GET_X_LPARAM(lp));
|
||||
float y = static_cast<float>(GET_Y_LPARAM(lp));
|
||||
Geo geo = ComputeGeo(w_, h_);
|
||||
if (pressed_ == 1 &&
|
||||
InRect(x, y, geo.minBtnX, 0, geo.btnW, geo.titleBarH)) {
|
||||
ShowWindow(hwnd_, SW_MINIMIZE);
|
||||
} else if (pressed_ == 2 &&
|
||||
InRect(x, y, geo.closeBtnX, 0, geo.btnW,
|
||||
geo.titleBarH)) {
|
||||
PostMessageW(hwnd_, WM_CLOSE, 0, 0);
|
||||
} else if (pressed_ == 3 && !injecting_) {
|
||||
for (const auto& r : rows_) {
|
||||
if (r.pid == pressedPid_ && onInjectRequested) {
|
||||
onInjectRequested(r.pid, r.title);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pressed_ = 0;
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_MOUSEWHEEL: {
|
||||
float delta =
|
||||
static_cast<float>(GET_WHEEL_DELTA_WPARAM(wp)) / 120.0f;
|
||||
float maxScroll = ContentHeight() -
|
||||
ComputeGeo(w_, h_).listBottom +
|
||||
ComputeGeo(w_, h_).listTop;
|
||||
scroll_ -= delta * Scl(48);
|
||||
if (scroll_ < 0) scroll_ = 0;
|
||||
if (scroll_ > maxScroll) scroll_ = maxScroll;
|
||||
Tick();
|
||||
return 0;
|
||||
}
|
||||
case WM_SETCURSOR:
|
||||
return 0; // cursor set in WM_MOUSEMOVE
|
||||
case WM_NCHITTEST: {
|
||||
// Title-bar drag anywhere on the custom bar except the buttons.
|
||||
POINT pt{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)};
|
||||
RECT wr;
|
||||
GetWindowRect(hwnd_, &wr);
|
||||
float y = static_cast<float>(pt.y - wr.top);
|
||||
float x = static_cast<float>(pt.x - wr.left);
|
||||
Geo geo = ComputeGeo(w_, h_);
|
||||
if (y >= 0 && y < geo.titleBarH &&
|
||||
!InRect(x, y, geo.minBtnX, 0, geo.btnW, geo.titleBarH) &&
|
||||
!InRect(x, y, geo.closeBtnX, 0, geo.btnW, geo.titleBarH)) {
|
||||
return HTCAPTION;
|
||||
}
|
||||
return HTCLIENT;
|
||||
}
|
||||
case WM_NCLBUTTONDBLCLK:
|
||||
return 0; // no maximize on double-click
|
||||
case WM_CLOSE:
|
||||
PlayExit();
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
KillTimer(hwnd_, kTimerAnim);
|
||||
KillTimer(hwnd_, kTimerPoll);
|
||||
hwnd_ = nullptr;
|
||||
if (onClosed) onClosed();
|
||||
return 0;
|
||||
default:
|
||||
return DefWindowProcW(hwnd_, m, wp, lp);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
//
|
||||
// main_win.h — main loader window (GDI+ replacement for MainWindow +
|
||||
// TitleBar + InstanceList + InstanceRow).
|
||||
//
|
||||
// A single frameless, per-pixel-alpha layered window, fully owner-drawn:
|
||||
// rounded gradient panel, custom title bar (pulsing status dot, minimize /
|
||||
// close), the scrolling Minecraft-instance list with hover-tinted rows and
|
||||
// gradient Inject buttons, and the rounded status strip. A 1 s poll refreshes
|
||||
// the instance list, a 30 fps timer drives the animations.
|
||||
//
|
||||
|
||||
#include "loader.h"
|
||||
#include "wgfx.h"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ui {
|
||||
|
||||
// True when a java process looks like a Minecraft window: its main window
|
||||
// title starts with "Minecraft" (in-game state) or the LWJGL GLFW window
|
||||
// class ("GLFW30") is in use (still true before the title gets set).
|
||||
bool LooksLikeMinecraft(const loader::JavaProcess& jp);
|
||||
|
||||
class MainWindow {
|
||||
public:
|
||||
// Fired with (pid, title) when the user clicks a row's Inject button.
|
||||
std::function<void(unsigned long, const std::wstring&)> onInjectRequested;
|
||||
// Fired when the window is gone (after the exit fade). main.cpp quits.
|
||||
std::function<void()> onClosed;
|
||||
|
||||
// Creates (hidden) and plays the entrance animation. Call after the
|
||||
// splash finishes.
|
||||
void PlayEntrance();
|
||||
|
||||
// Fade out, then destroy. Idempotent. If an injection is in flight the
|
||||
// window is torn down immediately (no fade) so the process can exit.
|
||||
void PlayExit();
|
||||
|
||||
// Called by main.cpp when the overlay takes over.
|
||||
void SetInjectionInFlight(bool on) { injecting_ = on; }
|
||||
|
||||
bool IsInjecting() const { return injecting_; }
|
||||
HWND hwnd() const { return hwnd_; }
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK Thunk(HWND, UINT, WPARAM, LPARAM);
|
||||
LRESULT Handle(UINT, WPARAM, LPARAM);
|
||||
|
||||
void Create();
|
||||
void Render();
|
||||
void Tick();
|
||||
void Refresh();
|
||||
|
||||
// --- layout helpers (shared by Render and hit-testing) ---
|
||||
struct Geo {
|
||||
float titleBarH;
|
||||
float bodyL, bodyR;
|
||||
float titleY, titleH;
|
||||
float hintY, hintH;
|
||||
float listTop, listBottom;
|
||||
float statusTop, statusH;
|
||||
float minBtnX, closeBtnX, btnW;
|
||||
float rowX0, rowX1;
|
||||
float scrollX, scrollW, scrollY0, scrollY1;
|
||||
};
|
||||
Geo ComputeGeo(int w, int h) const;
|
||||
float ContentHeight() const;
|
||||
float RowHoverValue(size_t i, double now) const;
|
||||
|
||||
void LayoutRowRect(const Geo& g, size_t i, float* y0, float* y1) const;
|
||||
|
||||
HWND hwnd_ = nullptr;
|
||||
LayeredCanvas canvas_;
|
||||
|
||||
struct Row {
|
||||
unsigned long pid = 0;
|
||||
std::wstring title;
|
||||
double born = 0.0; // entrance animation start
|
||||
double hoverStart = 0.0;
|
||||
float hoverFrom = 0.0f;
|
||||
bool hoverTarget = false;
|
||||
};
|
||||
std::vector<Row> rows_;
|
||||
std::wstring status_;
|
||||
|
||||
// scroll state
|
||||
float scroll_ = 0.0f; // current offset in px
|
||||
bool scrollDrag_ = false;
|
||||
float scrollGrab_ = 0.0f; // px inside the handle where the drag began
|
||||
|
||||
// mouse state
|
||||
int hoverRow_ = -1;
|
||||
bool hoverMin_ = false;
|
||||
bool hoverClose_ = false;
|
||||
int pressed_ = 0; // 1=min 2=close 3=inject row
|
||||
unsigned long pressedPid_ = 0;
|
||||
|
||||
// animation state
|
||||
double tEntrance_ = -1.0; // <0 = not started
|
||||
double tExit_ = -1.0;
|
||||
int baseX_ = 0, baseY_ = 0; // window position at rest
|
||||
int w_ = 0, h_ = 0;
|
||||
bool entranceDone_ = false;
|
||||
bool injecting_ = false;
|
||||
bool closing_ = false;
|
||||
|
||||
// jittered base size
|
||||
int baseW_ = 0, baseH_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,387 @@
|
||||
#include "manual_map.h"
|
||||
|
||||
#include <psapi.h>
|
||||
#include <tlhelp32.h>
|
||||
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "psapi.lib")
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
|
||||
std::wstring fmt_err(const wchar_t* where, DWORD err) {
|
||||
wchar_t msg[256] = {0};
|
||||
FormatMessageW(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
nullptr, err, 0, msg, 256, nullptr);
|
||||
std::wstringstream ss;
|
||||
ss << where << L" failed (" << err << L"): " << msg;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
const IMAGE_NT_HEADERS* nt_of(const void* base) {
|
||||
auto dos = static_cast<const IMAGE_DOS_HEADER*>(base);
|
||||
return reinterpret_cast<const IMAGE_NT_HEADERS*>(
|
||||
static_cast<const BYTE*>(base) + dos->e_lfanew);
|
||||
}
|
||||
|
||||
// Run LoadLibraryW(name) inside the target process and return the resulting
|
||||
// HMODULE seen by that process, or nullptr on failure.
|
||||
HMODULE remote_load_library(HANDLE process, const wchar_t* dll_name) {
|
||||
SIZE_T sz = (std::wcslen(dll_name) + 1) * sizeof(wchar_t);
|
||||
LPVOID arg = VirtualAllocEx(process, nullptr, sz,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
if (!arg) return nullptr;
|
||||
SIZE_T written = 0;
|
||||
if (!WriteProcessMemory(process, arg, dll_name, sz, &written) || written != sz) {
|
||||
VirtualFreeEx(process, arg, 0, MEM_RELEASE);
|
||||
return nullptr;
|
||||
}
|
||||
auto load_lib = reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(
|
||||
GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
|
||||
HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_lib, arg, 0, nullptr);
|
||||
if (!thread) {
|
||||
VirtualFreeEx(process, arg, 0, MEM_RELEASE);
|
||||
return nullptr;
|
||||
}
|
||||
WaitForSingleObject(thread, 10000);
|
||||
DWORD ret = 0;
|
||||
GetExitCodeThread(thread, &ret);
|
||||
CloseHandle(thread);
|
||||
VirtualFreeEx(process, arg, 0, MEM_RELEASE);
|
||||
// On x64 GetExitCodeThread returns a DWORD which truncates HMODULE, but
|
||||
// ASLR keeps HMODULEs within 32 bits for almost every module on Windows,
|
||||
// so this works in practice. The remote_module enumerator below is the
|
||||
// fallback if the truncation ever bites us.
|
||||
return reinterpret_cast<HMODULE>(static_cast<ULONG_PTR>(ret));
|
||||
}
|
||||
|
||||
HMODULE find_remote_module(HANDLE process, const wchar_t* name) {
|
||||
HMODULE mods[1024];
|
||||
DWORD cb = 0;
|
||||
if (!EnumProcessModulesEx(process, mods, sizeof mods, &cb, LIST_MODULES_ALL)) {
|
||||
return nullptr;
|
||||
}
|
||||
DWORD count = cb / sizeof(HMODULE);
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
wchar_t buf[MAX_PATH];
|
||||
if (GetModuleBaseNameW(process, mods[i], buf, MAX_PATH)) {
|
||||
if (_wcsicmp(buf, name) == 0) return mods[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Resolve the RVA of a named export directly from the in-memory image we built
|
||||
// locally. Relocations don't move the export RVA, so the remote symbol
|
||||
// address is simply remote_image_base + rva.
|
||||
DWORD find_export_rva(const BYTE* image, const IMAGE_NT_HEADERS* nt,
|
||||
const char* name) {
|
||||
const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
|
||||
if (dir.Size == 0) return 0;
|
||||
auto exp = reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(image + dir.VirtualAddress);
|
||||
if (exp->NumberOfNames == 0) return 0;
|
||||
auto names = reinterpret_cast<const DWORD*>(image + exp->AddressOfNames);
|
||||
auto funcs = reinterpret_cast<const DWORD*>(image + exp->AddressOfFunctions);
|
||||
auto ords = reinterpret_cast<const WORD*>(image + exp->AddressOfNameOrdinals);
|
||||
for (DWORD i = 0; i < exp->NumberOfNames; ++i) {
|
||||
const char* cand = reinterpret_cast<const char*>(image + names[i]);
|
||||
if (std::strcmp(cand, name) == 0) {
|
||||
return funcs[ords[i]];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Maps the injected DLL's bootstrap verdict code to a short human-readable
|
||||
// description. See dll/src/main.cpp for where the codes are produced.
|
||||
std::wstring bootstrap_verdict(DWORD code) {
|
||||
switch (code) {
|
||||
case 0: return L"fine (java bootstrap complete)";
|
||||
case 1: return L"no JavaVM in target (not a Java process?)";
|
||||
case 2: return L"AttachCurrentThreadAsDaemon failed";
|
||||
case 3: return L"failed to extract embedded zen.jar";
|
||||
case 4: return L"Minecraft/Forge classes not found (target is not a Forge MC instance)";
|
||||
case 5: return L"GameLoaderBridge class load failed";
|
||||
case 6: return L"GameLoaderBridge.load(String, ClassLoader) not found";
|
||||
case 7: return L"GameLoaderBridge.load threw (bootstrap.start failed)";
|
||||
case 8: return L"bootstrap thread creation failed";
|
||||
default: return L"bootstrap reported code " + std::to_wstring(code);
|
||||
}
|
||||
}
|
||||
|
||||
void apply_relocations(BYTE* image, const IMAGE_NT_HEADERS* nt, ULONGLONG delta) {
|
||||
if (delta == 0) return;
|
||||
const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
|
||||
if (dir.Size == 0) return;
|
||||
BYTE* block_ptr = image + dir.VirtualAddress;
|
||||
BYTE* end = block_ptr + dir.Size;
|
||||
while (block_ptr < end) {
|
||||
auto block = reinterpret_cast<IMAGE_BASE_RELOCATION*>(block_ptr);
|
||||
if (block->SizeOfBlock == 0) break;
|
||||
DWORD count = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
|
||||
auto entries = reinterpret_cast<WORD*>(block + 1);
|
||||
BYTE* page = image + block->VirtualAddress;
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
WORD type = entries[i] >> 12;
|
||||
WORD off = entries[i] & 0x0FFF;
|
||||
if (type == IMAGE_REL_BASED_DIR64) {
|
||||
*reinterpret_cast<ULONGLONG*>(page + off) += delta;
|
||||
} else if (type == IMAGE_REL_BASED_HIGHLOW) {
|
||||
*reinterpret_cast<DWORD*>(page + off) += static_cast<DWORD>(delta);
|
||||
}
|
||||
// IMAGE_REL_BASED_ABSOLUTE (0) is a padding entry; ignore.
|
||||
}
|
||||
block_ptr += block->SizeOfBlock;
|
||||
}
|
||||
}
|
||||
|
||||
bool resolve_imports(HANDLE process, BYTE* local_image,
|
||||
const IMAGE_NT_HEADERS* nt, std::wstring& err) {
|
||||
const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
|
||||
if (dir.Size == 0) return true;
|
||||
auto desc = reinterpret_cast<IMAGE_IMPORT_DESCRIPTOR*>(local_image + dir.VirtualAddress);
|
||||
|
||||
while (desc->Name) {
|
||||
const char* dll_name_ansi = reinterpret_cast<const char*>(local_image + desc->Name);
|
||||
wchar_t dll_name_w[MAX_PATH] = {0};
|
||||
MultiByteToWideChar(CP_ACP, 0, dll_name_ansi, -1, dll_name_w, MAX_PATH);
|
||||
|
||||
HMODULE remote_mod = find_remote_module(process, dll_name_w);
|
||||
if (!remote_mod) {
|
||||
remote_mod = remote_load_library(process, dll_name_w);
|
||||
if (!remote_mod) remote_mod = find_remote_module(process, dll_name_w);
|
||||
}
|
||||
if (!remote_mod) {
|
||||
std::wstringstream ss;
|
||||
ss << L"Remote LoadLibrary failed for dependency " << dll_name_w;
|
||||
err = ss.str();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the loader's own copy of the dependency to walk its export table
|
||||
// and compute remote function addresses. This works because Windows
|
||||
// resolves each DLL's exports to a constant RVA, so
|
||||
// remote_fn = remote_mod_base + (local_fn - local_mod_base)
|
||||
HMODULE local_mod = GetModuleHandleA(dll_name_ansi);
|
||||
if (!local_mod) local_mod = LoadLibraryA(dll_name_ansi);
|
||||
if (!local_mod) {
|
||||
std::wstringstream ss;
|
||||
ss << L"Local LoadLibrary failed for dependency " << dll_name_w;
|
||||
err = ss.str();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto thunk = reinterpret_cast<IMAGE_THUNK_DATA*>(local_image +
|
||||
(desc->OriginalFirstThunk ? desc->OriginalFirstThunk : desc->FirstThunk));
|
||||
auto iat = reinterpret_cast<IMAGE_THUNK_DATA*>(local_image + desc->FirstThunk);
|
||||
|
||||
while (thunk->u1.AddressOfData) {
|
||||
FARPROC local_fn = nullptr;
|
||||
if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)) {
|
||||
local_fn = GetProcAddress(local_mod,
|
||||
reinterpret_cast<LPCSTR>(IMAGE_ORDINAL(thunk->u1.Ordinal)));
|
||||
} else {
|
||||
auto by_name = reinterpret_cast<IMAGE_IMPORT_BY_NAME*>(
|
||||
local_image + thunk->u1.AddressOfData);
|
||||
local_fn = GetProcAddress(local_mod, by_name->Name);
|
||||
}
|
||||
if (local_fn) {
|
||||
ULONGLONG remote_fn = reinterpret_cast<ULONGLONG>(remote_mod) +
|
||||
(reinterpret_cast<ULONGLONG>(local_fn) -
|
||||
reinterpret_cast<ULONGLONG>(local_mod));
|
||||
iat->u1.Function = remote_fn;
|
||||
}
|
||||
++thunk;
|
||||
++iat;
|
||||
}
|
||||
++desc;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DWORD section_protection(DWORD characteristics) {
|
||||
bool x = (characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
|
||||
bool r = (characteristics & IMAGE_SCN_MEM_READ) != 0;
|
||||
bool w = (characteristics & IMAGE_SCN_MEM_WRITE) != 0;
|
||||
if (x && r && w) return PAGE_EXECUTE_READWRITE;
|
||||
if (x && r) return PAGE_EXECUTE_READ;
|
||||
if (x) return PAGE_EXECUTE;
|
||||
if (r && w) return PAGE_READWRITE;
|
||||
if (r) return PAGE_READONLY;
|
||||
return PAGE_NOACCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::wstring inject_in_memory(DWORD pid, const void* dll_bytes, size_t dll_size) {
|
||||
if (!dll_bytes || dll_size < sizeof(IMAGE_DOS_HEADER)) {
|
||||
return L"DLL payload too small";
|
||||
}
|
||||
auto dos = static_cast<const IMAGE_DOS_HEADER*>(dll_bytes);
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return L"Bad DOS signature";
|
||||
auto nt = nt_of(dll_bytes);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return L"Bad NT signature";
|
||||
if (nt->FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64) {
|
||||
return L"DLL is not x64 (only AMD64 supported)";
|
||||
}
|
||||
|
||||
HANDLE process = OpenProcess(
|
||||
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
|
||||
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
|
||||
FALSE, pid);
|
||||
if (!process) return fmt_err(L"OpenProcess", GetLastError());
|
||||
|
||||
SIZE_T image_size = nt->OptionalHeader.SizeOfImage;
|
||||
LPVOID remote_image = VirtualAllocEx(process, nullptr, image_size,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
|
||||
if (!remote_image) {
|
||||
DWORD err = GetLastError();
|
||||
CloseHandle(process);
|
||||
return fmt_err(L"VirtualAllocEx (image)", err);
|
||||
}
|
||||
|
||||
// Build the in-memory image locally before pushing it across, so we can
|
||||
// apply relocations + import patches in cheap local memory rather than
|
||||
// round-tripping ReadProcessMemory/WriteProcessMemory.
|
||||
std::vector<BYTE> local_image(image_size, 0);
|
||||
std::memcpy(local_image.data(), dll_bytes, nt->OptionalHeader.SizeOfHeaders);
|
||||
|
||||
auto sect = IMAGE_FIRST_SECTION(nt);
|
||||
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sect) {
|
||||
if (sect->SizeOfRawData == 0) continue;
|
||||
if (sect->PointerToRawData + sect->SizeOfRawData > dll_size) continue;
|
||||
std::memcpy(local_image.data() + sect->VirtualAddress,
|
||||
static_cast<const BYTE*>(dll_bytes) + sect->PointerToRawData,
|
||||
sect->SizeOfRawData);
|
||||
}
|
||||
|
||||
ULONGLONG delta = reinterpret_cast<ULONGLONG>(remote_image) -
|
||||
nt->OptionalHeader.ImageBase;
|
||||
apply_relocations(local_image.data(), nt, delta);
|
||||
|
||||
std::wstring imp_err;
|
||||
if (!resolve_imports(process, local_image.data(), nt, imp_err)) {
|
||||
VirtualFreeEx(process, remote_image, 0, MEM_RELEASE);
|
||||
CloseHandle(process);
|
||||
return imp_err;
|
||||
}
|
||||
|
||||
SIZE_T written = 0;
|
||||
if (!WriteProcessMemory(process, remote_image, local_image.data(),
|
||||
image_size, &written) || written != image_size) {
|
||||
DWORD err = GetLastError();
|
||||
VirtualFreeEx(process, remote_image, 0, MEM_RELEASE);
|
||||
CloseHandle(process);
|
||||
return fmt_err(L"WriteProcessMemory (image)", err);
|
||||
}
|
||||
|
||||
// Tighten section permissions to match the original PE characteristics.
|
||||
sect = IMAGE_FIRST_SECTION(nt);
|
||||
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sect) {
|
||||
if (sect->Misc.VirtualSize == 0) continue;
|
||||
DWORD prot = section_protection(sect->Characteristics);
|
||||
DWORD old = 0;
|
||||
VirtualProtectEx(process,
|
||||
static_cast<BYTE*>(remote_image) + sect->VirtualAddress,
|
||||
sect->Misc.VirtualSize, prot, &old);
|
||||
}
|
||||
|
||||
// CreateRemoteThread can only deliver one pointer-sized arg, so we drop a
|
||||
// tiny x64 trampoline that calls
|
||||
// DllMain(hModule = remote_image,
|
||||
// fdwReason = DLL_PROCESS_ATTACH,
|
||||
// lpvReserved = NULL)
|
||||
// through the standard MSVC ABI before returning.
|
||||
BYTE shellcode[] = {
|
||||
0x48, 0xB9, 0,0,0,0,0,0,0,0, // mov rcx, imm64 (hModule)
|
||||
0xBA, 0x01, 0x00, 0x00, 0x00, // mov edx, 1 (DLL_PROCESS_ATTACH)
|
||||
0x4D, 0x31, 0xC0, // xor r8, r8 (lpvReserved)
|
||||
0x48, 0xB8, 0,0,0,0,0,0,0,0, // mov rax, imm64 (entry point)
|
||||
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28 (16 + shadow space)
|
||||
0xFF, 0xD0, // call rax
|
||||
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
|
||||
0xC3 // ret
|
||||
};
|
||||
ULONGLONG hmod = reinterpret_cast<ULONGLONG>(remote_image);
|
||||
ULONGLONG entry = hmod + nt->OptionalHeader.AddressOfEntryPoint;
|
||||
std::memcpy(shellcode + 2, &hmod, sizeof hmod);
|
||||
std::memcpy(shellcode + 20, &entry, sizeof entry);
|
||||
|
||||
LPVOID remote_sc = VirtualAllocEx(process, nullptr, sizeof shellcode,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
|
||||
if (!remote_sc) {
|
||||
DWORD err = GetLastError();
|
||||
VirtualFreeEx(process, remote_image, 0, MEM_RELEASE);
|
||||
CloseHandle(process);
|
||||
return fmt_err(L"VirtualAllocEx (shellcode)", err);
|
||||
}
|
||||
if (!WriteProcessMemory(process, remote_sc, shellcode, sizeof shellcode, &written)
|
||||
|| written != sizeof shellcode) {
|
||||
DWORD err = GetLastError();
|
||||
VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE);
|
||||
VirtualFreeEx(process, remote_image, 0, MEM_RELEASE);
|
||||
CloseHandle(process);
|
||||
return fmt_err(L"WriteProcessMemory (shellcode)", err);
|
||||
}
|
||||
|
||||
HANDLE thread = CreateRemoteThread(process, nullptr, 0,
|
||||
reinterpret_cast<LPTHREAD_START_ROUTINE>(remote_sc), nullptr, 0, nullptr);
|
||||
if (!thread) {
|
||||
DWORD err = GetLastError();
|
||||
VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE);
|
||||
VirtualFreeEx(process, remote_image, 0, MEM_RELEASE);
|
||||
CloseHandle(process);
|
||||
return fmt_err(L"CreateRemoteThread", err);
|
||||
}
|
||||
WaitForSingleObject(thread, 30000);
|
||||
CloseHandle(thread);
|
||||
|
||||
VirtualFreeEx(process, remote_sc, 0, MEM_RELEASE);
|
||||
|
||||
// DllMain returning only means the DLL is mapped; the Java/bootstrap work
|
||||
// runs on a detached thread inside the target. Poll the exported
|
||||
// OpenZenBootstrapResult volatile so the caller gets a real verdict
|
||||
// ("bootstrap complete" vs "Minecraft class not found") instead of a
|
||||
// false success.
|
||||
std::wstring verdict_err;
|
||||
{
|
||||
DWORD rva = find_export_rva(local_image.data(), nt,
|
||||
"OpenZenBootstrapResult");
|
||||
if (rva != 0) {
|
||||
LPVOID remote_addr = static_cast<BYTE*>(remote_image) + rva;
|
||||
bool done = false;
|
||||
for (int i = 0; i < 150; ++i) { // up to 30 s
|
||||
LONG val = -1;
|
||||
SIZE_T read = 0;
|
||||
if (ReadProcessMemory(process, remote_addr, &val, sizeof val,
|
||||
&read) && read == sizeof val) {
|
||||
if (val != -1) {
|
||||
done = true;
|
||||
if (val != 0) {
|
||||
verdict_err =
|
||||
L"Java bootstrap failed: " +
|
||||
bootstrap_verdict(static_cast<DWORD>(val));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Sleep(200);
|
||||
}
|
||||
if (!done) {
|
||||
verdict_err =
|
||||
L"Timed out waiting for the Java bootstrap; "
|
||||
L"the native log (%TEMP%\\openzen-<pid>-*.log) has details";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave remote_image allocated - the DLL stays mapped in the target's
|
||||
// address space for the lifetime of the process.
|
||||
CloseHandle(process);
|
||||
return verdict_err;
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
#include <string>
|
||||
|
||||
namespace loader {
|
||||
|
||||
// Map a DLL image directly into the target process and invoke its entry point,
|
||||
// without writing the DLL to disk first. Implements a minimal PE loader:
|
||||
// VirtualAllocEx + relocations + import resolution + DllMain stub via
|
||||
// CreateRemoteThread shellcode.
|
||||
//
|
||||
// Returns an empty string on success, or a human-readable error message.
|
||||
std::wstring inject_in_memory(DWORD pid, const void* dll_bytes, size_t dll_size);
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,303 @@
|
||||
#include "overlay_win.h"
|
||||
|
||||
#include "loader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <memory>
|
||||
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
constexpr int kW = 460;
|
||||
constexpr int kH = 240;
|
||||
constexpr float kCornerRadius = 16.0f;
|
||||
|
||||
constexpr double kFadeIn = 0.22; // panel fade-in
|
||||
constexpr double kSpinnerT = 1.10; // one revolution
|
||||
constexpr double kCrawl = 0.90; // progress 0 -> 0.7
|
||||
constexpr double kFinish = 0.26; // progress -> 1.0
|
||||
constexpr double kMark = 0.38; // check/cross draw-in
|
||||
constexpr double kHold = 0.70; // hold after mark
|
||||
constexpr double kFadeOut = 0.26; // panel fade-out
|
||||
|
||||
constexpr UINT WM_APP_INJECT_RESULT = WM_APP + 1;
|
||||
|
||||
float Scl(float v) { return static_cast<float>(v * g_scale); }
|
||||
} // namespace
|
||||
|
||||
void OverlayWindow::Show(HWND centerOver, unsigned long pid,
|
||||
const std::wstring& target) {
|
||||
pid_ = pid;
|
||||
target_ = target;
|
||||
w_ = static_cast<int>(kW * g_scale);
|
||||
h_ = static_cast<int>(kH * g_scale);
|
||||
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = &OverlayWindow::Thunk;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"OZLoaderOverlay";
|
||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
RegisterClassW(&wc);
|
||||
|
||||
hwnd_ = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
wc.lpszClassName, L"", WS_POPUP, 0, 0, w_, h_,
|
||||
nullptr, nullptr, wc.hInstance, this);
|
||||
|
||||
// Centre over the parent window if given, else the work area.
|
||||
RECT rc{};
|
||||
if (centerOver && GetWindowRect(centerOver, &rc)) {
|
||||
int x = (rc.left + rc.right - w_) / 2;
|
||||
int y = (rc.top + rc.bottom - h_) / 2;
|
||||
SetWindowPos(hwnd_, HWND_TOPMOST, x, y, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
} else {
|
||||
RECT wa{};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0);
|
||||
int x = (wa.left + wa.right - w_) / 2;
|
||||
int y = (wa.top + wa.bottom - h_) / 2;
|
||||
SetWindowPos(hwnd_, HWND_TOPMOST, x, y, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
}
|
||||
|
||||
t0_ = Now();
|
||||
SetTimer(hwnd_, 1, 16, nullptr);
|
||||
|
||||
// Run the (synchronous, can-block) inject() on a worker thread so the
|
||||
// overlay keeps animating; the result bounces back via PostMessage.
|
||||
HWND targetWnd = hwnd_;
|
||||
worker_ = std::make_unique<std::thread>([targetWnd, pid]() {
|
||||
std::wstring err = loader::inject(pid);
|
||||
auto* boxed = new std::wstring(std::move(err));
|
||||
PostMessageW(targetWnd, WM_APP_INJECT_RESULT,
|
||||
static_cast<WPARAM>(boxed->empty()),
|
||||
reinterpret_cast<LPARAM>(boxed));
|
||||
});
|
||||
worker_->detach();
|
||||
}
|
||||
|
||||
void OverlayWindow::Tick() {
|
||||
if (!hwnd_) return;
|
||||
double now = Now();
|
||||
|
||||
// Sequence end: fire completed() then destroy.
|
||||
if (tResult_ >= 0.0) {
|
||||
double t = now - tResult_;
|
||||
if (t >= kFinish + kMark + kHold + kFadeOut) {
|
||||
if (!completed_) {
|
||||
completed_ = true;
|
||||
if (onCompleted) onCompleted(ok_);
|
||||
}
|
||||
DestroyWindow(hwnd_);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Render();
|
||||
|
||||
// Whole-window opacity: fade in, hold, fade out.
|
||||
double winOp;
|
||||
if (tResult_ < 0.0) {
|
||||
winOp = EaseOutCubic(Since(t0_, kFadeIn));
|
||||
} else {
|
||||
double t = now - tResult_;
|
||||
if (t < kFinish + kMark + kHold) {
|
||||
winOp = 1.0;
|
||||
} else {
|
||||
winOp = 1.0 - EaseInCubic((t - kFinish - kMark - kHold) / kFadeOut);
|
||||
}
|
||||
}
|
||||
canvas_.Present(hwnd_, static_cast<BYTE>(winOp * 255.0 + 0.5));
|
||||
}
|
||||
|
||||
void OverlayWindow::Render() {
|
||||
if (!canvas_.Resize(w_, h_)) return;
|
||||
Gdiplus::Graphics& g = canvas_.g();
|
||||
canvas_.Clear();
|
||||
double now = Now();
|
||||
double t = now - t0_;
|
||||
|
||||
Gdiplus::RectF rect(0.5f, 0.5f, w_ - 1.0f, h_ - 1.0f);
|
||||
Gdiplus::GraphicsPath* panel =
|
||||
RoundedRectPath(rect, Scl(kCornerRadius));
|
||||
|
||||
// Background.
|
||||
{
|
||||
Gdiplus::LinearGradientBrush bg(rect, Hex(0x1a1c22), Hex(0x0e1014),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&bg, panel);
|
||||
}
|
||||
// Accent glow behind the spinner.
|
||||
{
|
||||
float gcx = rect.X + rect.Width / 2.0f;
|
||||
float gcy = rect.Y + rect.Height / 2.0f - Scl(30);
|
||||
float r = rect.Width * 0.5f;
|
||||
Gdiplus::GraphicsPath glowPath;
|
||||
glowPath.AddEllipse(gcx - r, gcy - r, r * 2, r * 2);
|
||||
Gdiplus::PathGradientBrush glow(&glowPath);
|
||||
glow.SetCenterPoint(Gdiplus::PointF(gcx, gcy));
|
||||
glow.SetCenterColor(Rgba(70, 130, 230, 90));
|
||||
Gdiplus::Color surround[] = {Rgba(70, 130, 230, 0)};
|
||||
INT surroundCount = 1;
|
||||
glow.SetSurroundColors(surround, &surroundCount);
|
||||
g.FillRectangle(&glow, rect);
|
||||
}
|
||||
|
||||
// Spinner / status mark.
|
||||
float cx = rect.X + rect.Width / 2.0f;
|
||||
float cy = rect.Y + Scl(72.0f);
|
||||
float r = Scl(26.0f);
|
||||
bool injectDone = tResult_ >= 0.0;
|
||||
|
||||
if (!injectDone) {
|
||||
Gdiplus::Pen ringPen(Rgba(255, 255, 255, 30), Scl(3));
|
||||
g.DrawEllipse(&ringPen, cx - r, cy - r, r * 2, r * 2);
|
||||
|
||||
// Rotating 120-degree arc (GDI+ angles are clockwise from +x, which
|
||||
// matches the visual sweep the Qt version produced).
|
||||
double angle = fmod(t * 360.0 / kSpinnerT, 360.0);
|
||||
Gdiplus::Pen arcPen(Hex(0x7fb0ff), Scl(3));
|
||||
arcPen.SetStartCap(Gdiplus::LineCapRound);
|
||||
arcPen.SetEndCap(Gdiplus::LineCapRound);
|
||||
g.DrawArc(&arcPen, cx - r, cy - r, r * 2, r * 2,
|
||||
static_cast<float>(angle), 120.0f);
|
||||
} else {
|
||||
Gdiplus::Color accent = ok_ ? Hex(0x3ecf6b) : Hex(0xe25656);
|
||||
Gdiplus::SolidBrush markBrush(accent);
|
||||
g.FillEllipse(&markBrush, cx - r, cy - r, r * 2, r * 2);
|
||||
|
||||
Gdiplus::Pen mark(Gdiplus::Color(255, 255, 255), Scl(3));
|
||||
mark.SetStartCap(Gdiplus::LineCapRound);
|
||||
mark.SetEndCap(Gdiplus::LineCapRound);
|
||||
mark.SetLineJoin(Gdiplus::LineJoinRound);
|
||||
|
||||
double tm = Since(tResult_ + kFinish, kMark);
|
||||
float tt = static_cast<float>(EaseOutBack(tm));
|
||||
if (ok_) {
|
||||
// Two-segment checkmark animated by t.
|
||||
Gdiplus::PointF a(cx - Scl(11), cy + Scl(1));
|
||||
Gdiplus::PointF b(cx - Scl(2), cy + Scl(9));
|
||||
Gdiplus::PointF d(cx + Scl(12), cy - Scl(7));
|
||||
if (tt <= 0.5f) {
|
||||
float k = tt / 0.5f;
|
||||
g.DrawLine(&mark, a, Gdiplus::PointF(a.X + (b.X - a.X) * k,
|
||||
a.Y + (b.Y - a.Y) * k));
|
||||
} else {
|
||||
g.DrawLine(&mark, a, b);
|
||||
float k = (tt - 0.5f) / 0.5f;
|
||||
g.DrawLine(&mark, b, Gdiplus::PointF(b.X + (d.X - b.X) * k,
|
||||
b.Y + (d.Y - b.Y) * k));
|
||||
}
|
||||
} else {
|
||||
float off = Scl(11.0f) * tt;
|
||||
g.DrawLine(&mark, cx - off, cy - off, cx + off, cy + off);
|
||||
g.DrawLine(&mark, cx + off, cy - off, cx - off, cy + off);
|
||||
}
|
||||
}
|
||||
|
||||
// Status text.
|
||||
{
|
||||
Gdiplus::Font* f = Font(L"Segoe UI", Scl(12.0f * 96.0f / 72.0f), true);
|
||||
float y = rect.Y + Scl(140) - LineHeight(g, f) / 2.0f;
|
||||
DrawTextCentered(g, f, status_, cx, y, Hex(0xe3e7ef),
|
||||
rect.Width - Scl(40));
|
||||
}
|
||||
|
||||
// Target subtitle.
|
||||
{
|
||||
Gdiplus::Font* f = Font(L"Segoe UI", Scl(9.0f * 96.0f / 72.0f));
|
||||
float y = rect.Y + Scl(162) - LineHeight(g, f) / 2.0f;
|
||||
std::wstring sub =
|
||||
L"PID " + std::to_wstring(pid_) + L" \u00B7 " + target_;
|
||||
float maxW = rect.Width - Scl(60);
|
||||
if (TextWidth(g, f, sub) > maxW) sub = ElideMiddle(g, f, sub, maxW);
|
||||
DrawTextCentered(g, f, sub, cx, y, Hex(0x8a90a0), maxW + Scl(20));
|
||||
}
|
||||
|
||||
// Progress bar.
|
||||
{
|
||||
float pbY = rect.GetBottom() - Scl(38);
|
||||
float pbL = rect.X + Scl(64);
|
||||
float pbR = rect.GetRight() - Scl(64);
|
||||
float pbH = Scl(4);
|
||||
|
||||
Gdiplus::GraphicsPath* track =
|
||||
RoundedRectPath(Gdiplus::RectF(pbL, pbY - pbH / 2, pbR - pbL, pbH),
|
||||
pbH / 2);
|
||||
Gdiplus::SolidBrush trackBrush(Rgba(255, 255, 255, 22));
|
||||
g.FillPath(&trackBrush, track);
|
||||
delete track;
|
||||
|
||||
// progress: crawl to 70% while waiting, then finish to 100%.
|
||||
double p;
|
||||
if (tResult_ < 0.0) {
|
||||
p = 0.7 * EaseOutCubic(Since(t0_, kCrawl));
|
||||
} else {
|
||||
p = progressAtResult_ +
|
||||
(1.0 - progressAtResult_) * EaseOutCubic(Since(tResult_, kFinish));
|
||||
}
|
||||
float fillW = (pbR - pbL) * static_cast<float>(p);
|
||||
if (fillW > 0.5f) {
|
||||
Gdiplus::GraphicsPath* fill = RoundedRectPath(
|
||||
Gdiplus::RectF(pbL, pbY - pbH / 2, fillW, pbH), pbH / 2);
|
||||
Gdiplus::LinearGradientBrush grad(
|
||||
Gdiplus::RectF(pbL, pbY - pbH / 2, pbR - pbL, pbH),
|
||||
injectDone && !ok_ ? Hex(0xe25656) : Hex(0x3d6fd1),
|
||||
injectDone && !ok_ ? Hex(0xf08585) : Hex(0x7fb0ff),
|
||||
Gdiplus::LinearGradientModeHorizontal);
|
||||
g.FillPath(&grad, fill);
|
||||
delete fill;
|
||||
}
|
||||
}
|
||||
|
||||
// Border.
|
||||
{
|
||||
Gdiplus::Pen border(Rgba(255, 255, 255, 28), 1.0f);
|
||||
g.DrawPath(&border, panel);
|
||||
}
|
||||
delete panel;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK OverlayWindow::Thunk(HWND h, UINT m, WPARAM wp, LPARAM lp) {
|
||||
if (m == WM_NCCREATE) {
|
||||
auto* self = reinterpret_cast<OverlayWindow*>(
|
||||
reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);
|
||||
SetWindowLongPtrW(h, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||||
self->hwnd_ = h;
|
||||
}
|
||||
auto* self =
|
||||
reinterpret_cast<OverlayWindow*>(GetWindowLongPtrW(h, GWLP_USERDATA));
|
||||
return self ? self->Handle(m, wp, lp) : DefWindowProcW(h, m, wp, lp);
|
||||
}
|
||||
|
||||
LRESULT OverlayWindow::Handle(UINT m, WPARAM wp, LPARAM lp) {
|
||||
switch (m) {
|
||||
case WM_TIMER:
|
||||
Tick();
|
||||
return 0;
|
||||
case WM_APP_INJECT_RESULT: {
|
||||
auto* boxed = reinterpret_cast<std::wstring*>(lp);
|
||||
std::wstring err = std::move(*boxed);
|
||||
delete boxed;
|
||||
if (tResult_ < 0.0) {
|
||||
tResult_ = Now();
|
||||
ok_ = wp != 0;
|
||||
// Freeze the crawl value so the finish tween starts here.
|
||||
double p = 0.7 * EaseOutCubic(Since(t0_, kCrawl));
|
||||
progressAtResult_ = static_cast<float>(p);
|
||||
status_ = ok_ ? L"Injection complete"
|
||||
: L"Injection failed: " + err;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case WM_DESTROY:
|
||||
KillTimer(hwnd_, 1);
|
||||
hwnd_ = nullptr;
|
||||
return 0;
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
default:
|
||||
return DefWindowProcW(hwnd_, m, wp, lp);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
//
|
||||
// overlay_win.h — injection progress overlay (GDI+ replacement for
|
||||
// InjectionOverlay).
|
||||
//
|
||||
// Frameless top-most layered window shown over the main window while the
|
||||
// inject worker runs: spinner + crawling progress bar, then an animated
|
||||
// check / cross mark, a short hold and a fade-out before completed() fires.
|
||||
//
|
||||
|
||||
#include "wgfx.h"
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace ui {
|
||||
|
||||
class OverlayWindow {
|
||||
public:
|
||||
// Fired (on the UI thread) after the finish sequence, right before the
|
||||
// window destroys itself.
|
||||
std::function<void(bool ok)> onCompleted;
|
||||
|
||||
// `centerOver` — window to centre over (the main window).
|
||||
void Show(HWND centerOver, unsigned long pid, const std::wstring& target);
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK Thunk(HWND, UINT, WPARAM, LPARAM);
|
||||
LRESULT Handle(UINT, WPARAM, LPARAM);
|
||||
void Render();
|
||||
void Tick();
|
||||
|
||||
HWND hwnd_ = nullptr;
|
||||
LayeredCanvas canvas_;
|
||||
std::unique_ptr<std::thread> worker_;
|
||||
|
||||
unsigned long pid_ = 0;
|
||||
std::wstring target_;
|
||||
std::wstring status_ = L"Injecting OpenZen…";
|
||||
|
||||
double t0_ = 0.0; // sequence start
|
||||
double tResult_ = -1.0; // when the worker returned
|
||||
float progressAtResult_ = 0.0f;
|
||||
bool ok_ = false;
|
||||
int w_ = 0, h_ = 0;
|
||||
bool completed_ = false;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "loader.h"
|
||||
|
||||
#include <tlhelp32.h>
|
||||
#include <psapi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cwctype>
|
||||
|
||||
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<JavaProcess> list_java_processes() {
|
||||
std::vector<JavaProcess> 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);
|
||||
}
|
||||
WindowInfo wi = window_info_for(jp.pid);
|
||||
jp.window_title = std::move(wi.title);
|
||||
jp.window_class = std::move(wi.class_name);
|
||||
result.push_back(std::move(jp));
|
||||
} while (Process32NextW(snap, &pe));
|
||||
|
||||
CloseHandle(snap);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,228 @@
|
||||
#include "splash_win.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
constexpr int kWidth = 520;
|
||||
constexpr int kHeight = 280;
|
||||
constexpr float kCornerRadius = 18.0f;
|
||||
|
||||
constexpr double kTWinIn = 0.18; // window fade-in duration
|
||||
constexpr double kTLogoIn = 0.32; // wordmark alpha duration
|
||||
constexpr double kTLogoZoom = 0.48; // wordmark scale duration
|
||||
constexpr double kTGlow = 0.60; // glow pulse 0 -> 1 -> 0.35
|
||||
constexpr double kTScan = 0.58; // scan rail head travel
|
||||
constexpr double kTPause = 0.10; // hold between phase 1 and fade-out
|
||||
constexpr double kTFadeOut = 0.22; // window fade-out duration
|
||||
constexpr double kTTotal = kTGlow + kTPause + kTFadeOut;
|
||||
} // namespace
|
||||
|
||||
void SplashWindow::Show() {
|
||||
w_ = static_cast<int>(kWidth * g_scale);
|
||||
h_ = static_cast<int>(kHeight * g_scale);
|
||||
|
||||
WNDCLASSW wc{};
|
||||
wc.lpfnWndProc = &SplashWindow::Thunk;
|
||||
wc.hInstance = GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"OZLoaderSplash";
|
||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
RegisterClassW(&wc);
|
||||
|
||||
hwnd_ = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||
wc.lpszClassName, L"", WS_POPUP, 0, 0, w_, h_,
|
||||
nullptr, nullptr, wc.hInstance, this);
|
||||
|
||||
// Centre on the primary monitor's work area.
|
||||
RECT wa{};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0);
|
||||
int x = (wa.left + wa.right - w_) / 2;
|
||||
int y = (wa.top + wa.bottom - h_) / 2;
|
||||
SetWindowPos(hwnd_, HWND_TOPMOST, x, y, w_, h_,
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
|
||||
t0_ = Now();
|
||||
SetTimer(hwnd_, 1, 16, nullptr);
|
||||
}
|
||||
|
||||
void SplashWindow::Tick() {
|
||||
double t = Now() - t0_;
|
||||
if (t >= kTTotal) {
|
||||
if (!notified_) {
|
||||
notified_ = true;
|
||||
if (onFinished) onFinished();
|
||||
}
|
||||
DestroyWindow(hwnd_);
|
||||
return;
|
||||
}
|
||||
Render();
|
||||
|
||||
// Whole-window opacity: fade-in, hold, fade-out.
|
||||
double winOp;
|
||||
if (t < kTWinIn) {
|
||||
winOp = EaseOutCubic(t / kTWinIn);
|
||||
} else if (t < kTGlow + kTPause) {
|
||||
winOp = 1.0;
|
||||
} else {
|
||||
winOp = 1.0 - EaseInCubic((t - kTGlow - kTPause) / kTFadeOut);
|
||||
}
|
||||
canvas_.Present(hwnd_, static_cast<BYTE>(winOp * 255.0 + 0.5));
|
||||
}
|
||||
|
||||
void SplashWindow::Render() {
|
||||
if (!canvas_.Resize(w_, h_)) return;
|
||||
Gdiplus::Graphics& g = canvas_.g();
|
||||
canvas_.Clear();
|
||||
|
||||
double t = Now() - t0_;
|
||||
|
||||
double logoAlpha = Phase(t, 0.0, kTLogoIn, EaseOutCubic);
|
||||
double logoScale = 0.55 + 0.45 * Phase(t, 0.0, kTLogoZoom, EaseOutBack);
|
||||
double glowPulse;
|
||||
if (t < kTGlow / 2.0) {
|
||||
glowPulse = EaseInOutSine(t / (kTGlow / 2.0));
|
||||
} else {
|
||||
double u = Phase(t, kTGlow / 2.0, kTGlow, EaseInOutSine);
|
||||
glowPulse = 1.0 - 0.65 * u; // 1 -> 0.35
|
||||
}
|
||||
double scan = Phase(t, 0.0, kTScan, EaseInOutQuad);
|
||||
|
||||
Gdiplus::RectF rect(0.5f, 0.5f, w_ - 1.0f, h_ - 1.0f);
|
||||
Gdiplus::GraphicsPath* panel = RoundedRectPath(rect, kCornerRadius * static_cast<float>(g_scale));
|
||||
|
||||
// Layered background: dark vertical gradient + accent glow.
|
||||
const float ccx = rect.X + rect.Width / 2.0f;
|
||||
const float ccy = rect.Y + rect.Height / 2.0f;
|
||||
{
|
||||
Gdiplus::LinearGradientBrush bg(rect, Hex(0x16181f), Hex(0x0a0b0e),
|
||||
Gdiplus::LinearGradientModeVertical);
|
||||
g.FillPath(&bg, panel);
|
||||
}
|
||||
{
|
||||
// Radial glow behind the wordmark; radius/alpha track glowPulse.
|
||||
float r = rect.Width * static_cast<float>(0.45 + 0.07 * glowPulse);
|
||||
float glowCy = ccy - 4.0f * static_cast<float>(g_scale);
|
||||
Gdiplus::GraphicsPath glowPath;
|
||||
glowPath.AddEllipse(ccx - r, glowCy - r, r * 2.0f, r * 2.0f);
|
||||
int peak = static_cast<int>(120 * (0.45 + 0.55 * glowPulse));
|
||||
Gdiplus::PathGradientBrush glow(&glowPath);
|
||||
glow.SetCenterPoint(Gdiplus::PointF(ccx, glowCy));
|
||||
glow.SetCenterColor(Rgba(85, 135, 235, peak));
|
||||
Gdiplus::Color surround[] = {Rgba(85, 135, 235, peak / 4),
|
||||
Rgba(85, 135, 235, 0)};
|
||||
INT surroundCount = 2;
|
||||
glow.SetSurroundColors(surround, &surroundCount);
|
||||
g.FillRectangle(&glow, rect);
|
||||
}
|
||||
|
||||
// Wordmark: scale around the centre, soft drop shadow for legibility.
|
||||
{
|
||||
float logoPx = 40.0f * (96.0f / 72.0f) * static_cast<float>(g_scale); // 40pt -> px
|
||||
Gdiplus::Font* f = Font(L"Segoe UI", logoPx, /*bold=*/true);
|
||||
const std::wstring text = L"OpenZen";
|
||||
|
||||
// Per-character advance * 1.02 reproduces Qt's 102% letter spacing.
|
||||
float textW = 0.0f;
|
||||
for (wchar_t c : text) {
|
||||
std::wstring one(1, c);
|
||||
textW += TextWidth(g, f, one) * 1.02f;
|
||||
}
|
||||
float lineH = LineHeight(g, f);
|
||||
float cx = ccx;
|
||||
float cy = ccy;
|
||||
float left = cx - textW / 2.0f;
|
||||
float baseY = cy - lineH / 2.0f - 1.0f * static_cast<float>(g_scale);
|
||||
|
||||
g.TranslateTransform(cx, cy);
|
||||
g.ScaleTransform(static_cast<float>(logoScale), static_cast<float>(logoScale));
|
||||
g.TranslateTransform(-cx, -cy);
|
||||
|
||||
{
|
||||
Gdiplus::SolidBrush shadow(Rgba(0, 0, 0, static_cast<int>(90 * logoAlpha)));
|
||||
float x = left;
|
||||
for (wchar_t c : text) {
|
||||
std::wstring one(1, c);
|
||||
float w = TextWidth(g, f, one);
|
||||
g.DrawString(one.c_str(), 1, f,
|
||||
Gdiplus::RectF(x + 1.0f, baseY + 1.0f, w + 40.0f, lineH + 12.0f),
|
||||
&NearFormat(), &shadow);
|
||||
x += w * 1.02f;
|
||||
}
|
||||
}
|
||||
{
|
||||
Gdiplus::SolidBrush white(Gdiplus::Color(
|
||||
static_cast<BYTE>(255 * logoAlpha), 255, 255, 255));
|
||||
float x = left;
|
||||
for (wchar_t c : text) {
|
||||
std::wstring one(1, c);
|
||||
float w = TextWidth(g, f, one);
|
||||
g.DrawString(one.c_str(), 1, f,
|
||||
Gdiplus::RectF(x, baseY, w + 40.0f, lineH + 12.0f),
|
||||
&NearFormat(), &white);
|
||||
x += w * 1.02f;
|
||||
}
|
||||
}
|
||||
g.ResetTransform();
|
||||
}
|
||||
|
||||
// Scan rail near the bottom: faint baseline, gradient trail, bright head.
|
||||
{
|
||||
float trackY = rect.GetBottom() - 38.0f * static_cast<float>(g_scale);
|
||||
float trackL = rect.X + 70.0f * static_cast<float>(g_scale);
|
||||
float trackR = rect.GetRight() - 70.0f * static_cast<float>(g_scale);
|
||||
float trackW = trackR - trackL;
|
||||
|
||||
Gdiplus::Pen basePen(Rgba(255, 255, 255, 30), 1.0f);
|
||||
g.DrawLine(&basePen, trackL, trackY, trackR, trackY);
|
||||
|
||||
float headX = trackL + trackW * static_cast<float>(scan);
|
||||
float tailStart = headX - 140.0f * static_cast<float>(g_scale);
|
||||
if (tailStart < trackL) tailStart = trackL;
|
||||
Gdiplus::LinearGradientBrush trail(
|
||||
Gdiplus::RectF(tailStart, trackY - 2.0f, headX - tailStart + 0.01f, 4.0f),
|
||||
Rgba(80, 130, 230, 0), Rgba(130, 180, 255, 230),
|
||||
Gdiplus::LinearGradientModeHorizontal);
|
||||
Gdiplus::Pen trailPen(&trail, 2.0f);
|
||||
if (headX > tailStart) g.DrawLine(&trailPen, tailStart, trackY, headX, trackY);
|
||||
|
||||
Gdiplus::SolidBrush head(Rgba(180, 210, 255, 230));
|
||||
g.FillEllipse(&head, headX - 3.5f, trackY - 3.5f, 7.0f, 7.0f);
|
||||
}
|
||||
|
||||
// Hairline border.
|
||||
{
|
||||
Gdiplus::Pen border(Rgba(255, 255, 255, 28), 1.0f);
|
||||
g.DrawPath(&border, panel);
|
||||
}
|
||||
delete panel;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK SplashWindow::Thunk(HWND h, UINT m, WPARAM wp, LPARAM lp) {
|
||||
if (m == WM_NCCREATE) {
|
||||
auto* self = reinterpret_cast<SplashWindow*>(
|
||||
reinterpret_cast<CREATESTRUCTW*>(lp)->lpCreateParams);
|
||||
SetWindowLongPtrW(h, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||||
self->hwnd_ = h;
|
||||
}
|
||||
auto* self = reinterpret_cast<SplashWindow*>(GetWindowLongPtrW(h, GWLP_USERDATA));
|
||||
return self ? self->Handle(m, wp, lp) : DefWindowProcW(h, m, wp, lp);
|
||||
}
|
||||
|
||||
LRESULT SplashWindow::Handle(UINT m, WPARAM wp, LPARAM lp) {
|
||||
switch (m) {
|
||||
case WM_TIMER:
|
||||
Tick();
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
KillTimer(hwnd_, 1);
|
||||
hwnd_ = nullptr;
|
||||
return 0;
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
default:
|
||||
return DefWindowProcW(hwnd_, m, wp, lp);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
//
|
||||
// splash_win.h — cold-start splash (GDI+ replacement for SplashScreen).
|
||||
//
|
||||
// Frameless, top-most, per-pixel-alpha layered window that animates the
|
||||
// OpenZen wordmark (scale-in with overshoot + fade), a pulsing accent glow
|
||||
// and a scanning rail, then fades out and invokes onFinished (~920 ms).
|
||||
//
|
||||
|
||||
#include "wgfx.h"
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace ui {
|
||||
|
||||
class SplashWindow {
|
||||
public:
|
||||
// Invoked once when the splash sequence completes (before the window is
|
||||
// destroyed). Wired by main.cpp to MainWindow::PlayEntrance().
|
||||
std::function<void()> onFinished;
|
||||
|
||||
void Show();
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK Thunk(HWND, UINT, WPARAM, LPARAM);
|
||||
LRESULT Handle(UINT, WPARAM, LPARAM);
|
||||
void Render();
|
||||
void Tick();
|
||||
|
||||
HWND hwnd_ = nullptr;
|
||||
LayeredCanvas canvas_;
|
||||
double t0_ = 0.0;
|
||||
bool notified_ = false;
|
||||
int w_ = 0, h_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "wgfx.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cwchar>
|
||||
#include <random>
|
||||
|
||||
#pragma comment(lib, "gdiplus.lib")
|
||||
|
||||
using namespace Gdiplus;
|
||||
|
||||
namespace ui {
|
||||
|
||||
double g_scale = 1.0;
|
||||
|
||||
void InitDpi() {
|
||||
// Per-monitor v2 when available; plain aware as fallback. Either way the
|
||||
// process stops getting bitmap-stretched by DWM and we can scale layout
|
||||
// metrics by the real DPI.
|
||||
using SetCtxFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
if (auto fn = reinterpret_cast<SetCtxFn>(
|
||||
reinterpret_cast<void*>(GetProcAddress(
|
||||
user32, "SetProcessDpiAwarenessContext")))) {
|
||||
if (!fn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) {
|
||||
fn(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE);
|
||||
}
|
||||
} else {
|
||||
SetProcessDPIAware();
|
||||
}
|
||||
HDC dc = GetDC(nullptr);
|
||||
g_scale = GetDeviceCaps(dc, LOGPIXELSX) / 96.0;
|
||||
ReleaseDC(nullptr, dc);
|
||||
}
|
||||
|
||||
double Now() {
|
||||
using namespace std::chrono;
|
||||
return duration<double>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
double EaseLinear(double t) { return t; }
|
||||
|
||||
double EaseOutCubic(double t) {
|
||||
double u = 1.0 - t;
|
||||
return 1.0 - u * u * u;
|
||||
}
|
||||
|
||||
double EaseInCubic(double t) { return t * t * t; }
|
||||
|
||||
double EaseInOutSine(double t) { return 0.5 - 0.5 * cos(t * 3.14159265358979); }
|
||||
|
||||
double EaseInOutQuad(double t) {
|
||||
return t < 0.5 ? 2.0 * t * t : 1.0 - pow(-2.0 * t + 2.0, 2.0) / 2.0;
|
||||
}
|
||||
|
||||
double EaseOutBack(double t) {
|
||||
// Mirrors Qt's OutBack default overshoot (s = 1.70158).
|
||||
const double s = 1.70158;
|
||||
double u = t - 1.0;
|
||||
return 1.0 + (s + 1.0) * u * u * u + s * u * u;
|
||||
}
|
||||
|
||||
double Since(double start, double dur) {
|
||||
if (dur <= 0.0) return 1.0;
|
||||
double t = (Now() - start) / dur;
|
||||
if (t < 0.0) t = 0.0;
|
||||
if (t > 1.0) t = 1.0;
|
||||
return t;
|
||||
}
|
||||
|
||||
double Phase(double t, double t0, double t1, double (*ease)(double)) {
|
||||
if (t <= t0) return 0.0;
|
||||
if (t >= t1) return 1.0;
|
||||
return ease((t - t0) / (t1 - t0));
|
||||
}
|
||||
|
||||
Gdiplus::Color Rgba(int r, int g, int b, int a) {
|
||||
return Gdiplus::Color(static_cast<BYTE>(a), static_cast<BYTE>(r),
|
||||
static_cast<BYTE>(g), static_cast<BYTE>(b));
|
||||
}
|
||||
|
||||
Gdiplus::Color Hex(unsigned rgb, int a) {
|
||||
return Rgba((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, a);
|
||||
}
|
||||
|
||||
Gdiplus::GraphicsPath* RoundedRectPath(const Gdiplus::RectF& r, float radius) {
|
||||
auto* p = new Gdiplus::GraphicsPath();
|
||||
float rad = radius;
|
||||
float m = r.Width < r.Height ? r.Width : r.Height;
|
||||
m /= 2.0f;
|
||||
if (rad > m) rad = m;
|
||||
if (rad <= 0.0f) {
|
||||
p->AddRectangle(r);
|
||||
return p;
|
||||
}
|
||||
float d = rad * 2.0f;
|
||||
p->AddArc(r.X, r.Y, d, d, 180.0f, 90.0f);
|
||||
p->AddArc(r.X + r.Width - d, r.Y, d, d, 270.0f, 90.0f);
|
||||
p->AddArc(r.X + r.Width - d, r.Y + r.Height - d, d, d, 0.0f, 90.0f);
|
||||
p->AddArc(r.X, r.Y + r.Height - d, d, d, 90.0f, 90.0f);
|
||||
p->CloseFigure();
|
||||
return p;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::map<std::wstring, Gdiplus::Font*>& FontCache() {
|
||||
static std::map<std::wstring, Gdiplus::Font*> cache;
|
||||
return cache;
|
||||
}
|
||||
|
||||
const Gdiplus::FontFamily& Family(const wchar_t* name) {
|
||||
static std::map<std::wstring, Gdiplus::FontFamily*> fams;
|
||||
auto it = fams.find(name);
|
||||
if (it != fams.end()) return *it->second;
|
||||
auto* f = new Gdiplus::FontFamily(name);
|
||||
if (f->GetLastStatus() != Gdiplus::Ok) {
|
||||
delete f;
|
||||
f = new Gdiplus::FontFamily(L"Segoe UI"); // fallback
|
||||
}
|
||||
fams[name] = f;
|
||||
return *f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Gdiplus::StringFormat& NearFormat() {
|
||||
static StringFormat sf(StringFormat::GenericDefault());
|
||||
return sf;
|
||||
}
|
||||
|
||||
Gdiplus::Font* Font(const wchar_t* family, float px, bool bold, bool italic) {
|
||||
int style = bold ? FontStyleBold : FontStyleRegular;
|
||||
if (italic) style |= FontStyleItalic;
|
||||
wchar_t key[128];
|
||||
_snwprintf_s(key, _TRUNCATE, L"%s|%.1f|%d", family, px, style);
|
||||
auto& cache = FontCache();
|
||||
auto it = cache.find(key);
|
||||
if (it != cache.end()) return it->second;
|
||||
auto* f = new Gdiplus::Font(&Family(family), px, style, UnitPixel);
|
||||
cache[key] = f;
|
||||
return f;
|
||||
}
|
||||
|
||||
float LineHeight(Gdiplus::Graphics& g, Gdiplus::Font* f) {
|
||||
return f->GetHeight(&g);
|
||||
}
|
||||
|
||||
float TextWidth(Gdiplus::Graphics& g, Gdiplus::Font* f, const std::wstring& s) {
|
||||
Gdiplus::RectF bounds;
|
||||
g.MeasureString(s.c_str(), static_cast<INT>(s.size()), f,
|
||||
Gdiplus::RectF(0, 0, 10000, 10000), &NearFormat(), &bounds);
|
||||
return bounds.Width;
|
||||
}
|
||||
|
||||
void DrawText(Gdiplus::Graphics& g, Gdiplus::Font* f, const std::wstring& s,
|
||||
float x, float y, const Gdiplus::Color& c, float wrapWidth) {
|
||||
Gdiplus::SolidBrush brush(c);
|
||||
Gdiplus::RectF layout(x, y, wrapWidth, LineHeight(g, f) * 3.0f + 4.0f);
|
||||
g.DrawString(s.c_str(), static_cast<INT>(s.size()), f, layout,
|
||||
&NearFormat(), &brush);
|
||||
}
|
||||
|
||||
void DrawTextCentered(Gdiplus::Graphics& g, Gdiplus::Font* f,
|
||||
const std::wstring& s, float centerX, float y,
|
||||
const Gdiplus::Color& c, float wrapWidth) {
|
||||
Gdiplus::SolidBrush brush(c);
|
||||
Gdiplus::RectF layout(centerX - wrapWidth / 2.0f, y, wrapWidth,
|
||||
LineHeight(g, f) * 3.0f + 4.0f);
|
||||
StringFormat sf(&NearFormat());
|
||||
sf.SetAlignment(StringAlignmentCenter);
|
||||
g.DrawString(s.c_str(), static_cast<INT>(s.size()), f, layout, &sf,
|
||||
&brush);
|
||||
}
|
||||
|
||||
std::wstring ElideMiddle(Gdiplus::Graphics& g, Gdiplus::Font* f,
|
||||
const std::wstring& s, float maxW) {
|
||||
if (TextWidth(g, f, s) <= maxW || s.size() < 3) return s;
|
||||
size_t head = s.size() / 2, tail = s.size() / 2;
|
||||
// Shrink the middle one char at a time until it fits, then insert an
|
||||
// ellipsis in the gap.
|
||||
while (head > 0 && tail < s.size()) {
|
||||
std::wstring cut = s.substr(0, head) + L'\u2026' + s.substr(tail);
|
||||
if (TextWidth(g, f, cut) <= maxW) return cut;
|
||||
// Trim alternately from the middle outwards.
|
||||
if (head > tail - s.size() / 2) --head; else ++tail;
|
||||
}
|
||||
return s.substr(0, head) + L'\u2026';
|
||||
}
|
||||
|
||||
// ----- LayeredCanvas -----
|
||||
|
||||
LayeredCanvas::~LayeredCanvas() { Free(); }
|
||||
|
||||
void LayeredCanvas::Free() {
|
||||
delete gfx_;
|
||||
delete wrap_;
|
||||
if (bmp_) DeleteObject(bmp_);
|
||||
if (dc_) DeleteDC(dc_);
|
||||
gfx_ = nullptr;
|
||||
wrap_ = nullptr;
|
||||
bmp_ = nullptr;
|
||||
dc_ = nullptr;
|
||||
bits_ = nullptr;
|
||||
w_ = h_ = 0;
|
||||
}
|
||||
|
||||
bool LayeredCanvas::Resize(int w, int h) {
|
||||
if (w < 1 || h < 1) return false;
|
||||
if (w == w_ && h == h_) return true;
|
||||
Free();
|
||||
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
bi.bmiHeader.biWidth = w;
|
||||
bi.bmiHeader.biHeight = -h; // top-down
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
dc_ = CreateCompatibleDC(nullptr);
|
||||
bmp_ = CreateDIBSection(nullptr, &bi, DIB_RGB_COLORS, &bits_, nullptr, 0);
|
||||
if (!dc_ || !bmp_ || !bits_) {
|
||||
Free();
|
||||
return false;
|
||||
}
|
||||
SelectObject(dc_, bmp_);
|
||||
|
||||
// Wrap the DIB memory as a premultiplied-ARGB GDI+ bitmap: everything
|
||||
// drawn through gfx_ lands directly in the DIB, already in the exact
|
||||
// format UpdateLayeredWindow expects.
|
||||
wrap_ = new Gdiplus::Bitmap(w, h, w * 4, PixelFormat32bppPARGB,
|
||||
static_cast<BYTE*>(bits_));
|
||||
gfx_ = new Gdiplus::Graphics(wrap_);
|
||||
if (gfx_->GetLastStatus() != Gdiplus::Ok) {
|
||||
Free();
|
||||
return false;
|
||||
}
|
||||
gfx_->SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
|
||||
gfx_->SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
|
||||
gfx_->SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
|
||||
w_ = w;
|
||||
h_ = h;
|
||||
return true;
|
||||
}
|
||||
|
||||
void LayeredCanvas::Clear() {
|
||||
if (gfx_) gfx_->Clear(Gdiplus::Color(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
Gdiplus::Graphics& LayeredCanvas::g() { return *gfx_; }
|
||||
|
||||
bool LayeredCanvas::Present(HWND hwnd, BYTE constAlpha) {
|
||||
if (!dc_ || !hwnd) return false;
|
||||
HDC screen = GetDC(nullptr);
|
||||
POINT src{0, 0};
|
||||
SIZE sz{w_, h_};
|
||||
BLENDFUNCTION bf{AC_SRC_OVER, 0, constAlpha, AC_SRC_ALPHA};
|
||||
BOOL ok = UpdateLayeredWindow(hwnd, screen, nullptr, &sz, dc_, &src, 0,
|
||||
&bf, ULW_ALPHA);
|
||||
ReleaseDC(nullptr, screen);
|
||||
return ok != FALSE;
|
||||
}
|
||||
|
||||
std::wstring RandomIdent(int minLen, int maxLen) {
|
||||
static const wchar_t kAlphabet[] =
|
||||
L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const int kLetters = 52;
|
||||
const int kAll = static_cast<int>(wcslen(kAlphabet));
|
||||
std::mt19937 rng(static_cast<unsigned>(
|
||||
std::random_device{}() ^
|
||||
(std::hash<double>{}(Now()) << 1)));
|
||||
int len = minLen + static_cast<int>(rng() % (maxLen - minLen + 1));
|
||||
std::wstring s;
|
||||
s.reserve(len);
|
||||
s += kAlphabet[rng() % kLetters];
|
||||
for (int i = 1; i < len; ++i) s += kAlphabet[rng() % kAll];
|
||||
return s;
|
||||
}
|
||||
|
||||
void LogLine(const std::wstring& msg) {
|
||||
wchar_t temp[MAX_PATH]{};
|
||||
GetTempPathW(MAX_PATH, temp);
|
||||
std::wstring path = std::wstring(temp) + L"openzen-loader.log";
|
||||
HANDLE f = CreateFileW(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ,
|
||||
nullptr, OPEN_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (f == INVALID_HANDLE_VALUE) return;
|
||||
SYSTEMTIME st{};
|
||||
GetLocalTime(&st);
|
||||
wchar_t line[1024];
|
||||
_snwprintf_s(line, _TRUNCATE,
|
||||
L"[%02u:%02u:%02u.%03u] %s\r\n",
|
||||
st.wHour, st.wMinute, st.wSecond, st.wMilliseconds,
|
||||
msg.c_str());
|
||||
DWORD written = 0;
|
||||
WriteFile(f, line, static_cast<DWORD>(wcslen(line) * sizeof(wchar_t)),
|
||||
&written, nullptr);
|
||||
CloseHandle(f);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,112 @@
|
||||
#pragma once
|
||||
//
|
||||
// wgfx.h — minimal GDI+ drawing toolkit for the Win32 OpenZen loader UI.
|
||||
//
|
||||
// The loader GUI used to be Qt Widgets (static Qt6 via vcpkg). That was the
|
||||
// single heaviest build dependency (30+ min first build, mirror workarounds
|
||||
// for github downloads). The whole UI is now owner-drawn with GDI+ — which
|
||||
// ships with Windows — onto per-pixel-alpha layered windows, reproducing the
|
||||
// old look: rounded gradient panels, hover animations, pulsing indicators,
|
||||
// spinner/progress overlay, splash, etc. No third-party dependencies remain.
|
||||
//
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h> // IStream etc. — skipped by WIN32_LEAN_AND_MEAN, gdiplus needs it
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
namespace ui {
|
||||
|
||||
// Global UI scale (device pixels per 96-dpi logical pixel).
|
||||
extern double g_scale;
|
||||
|
||||
// Enables per-monitor DPI awareness and fills in g_scale. Call once before
|
||||
// creating any windows.
|
||||
void InitDpi();
|
||||
|
||||
// Seconds from a monotonic clock (animation timelines).
|
||||
double Now();
|
||||
|
||||
// --- easing curves (same shapes as the QEasingCurve values the Qt UI used) ---
|
||||
double EaseLinear(double t);
|
||||
double EaseOutCubic(double t);
|
||||
double EaseInCubic(double t);
|
||||
double EaseInOutSine(double t);
|
||||
double EaseInOutQuad(double t);
|
||||
double EaseOutBack(double t);
|
||||
|
||||
// Clamped 0..1 progress of `t` seconds elapsed since `start`.
|
||||
double Since(double start, double dur);
|
||||
|
||||
// Maps 0..1 sub-range [t0,t1] of `t` through `ease`.
|
||||
double Phase(double t, double t0, double t1, double (*ease)(double));
|
||||
|
||||
Gdiplus::Color Rgba(int r, int g, int b, int a = 255);
|
||||
|
||||
// Parsed "#rrggbb".
|
||||
Gdiplus::Color Hex(unsigned rgb, int a = 255);
|
||||
|
||||
// Rounded-rectangle path (caller deletes).
|
||||
Gdiplus::GraphicsPath* RoundedRectPath(const Gdiplus::RectF& r, float radius);
|
||||
|
||||
// --- fonts (cached) ---
|
||||
Gdiplus::Font* Font(const wchar_t* family, float px,
|
||||
bool bold = false, bool italic = false);
|
||||
Gdiplus::StringFormat& NearFormat();
|
||||
float LineHeight(Gdiplus::Graphics& g, Gdiplus::Font* f);
|
||||
float TextWidth(Gdiplus::Graphics& g, Gdiplus::Font* f, const std::wstring& s);
|
||||
|
||||
void DrawText(Gdiplus::Graphics& g, Gdiplus::Font* f, const std::wstring& s,
|
||||
float x, float y, const Gdiplus::Color& c,
|
||||
float wrapWidth = 10000.0f);
|
||||
void DrawTextCentered(Gdiplus::Graphics& g, Gdiplus::Font* f,
|
||||
const std::wstring& s, float centerX, float y,
|
||||
const Gdiplus::Color& c, float wrapWidth = 10000.0f);
|
||||
|
||||
// Middle ellipsis when the string measures wider than maxW.
|
||||
std::wstring ElideMiddle(Gdiplus::Graphics& g, Gdiplus::Font* f,
|
||||
const std::wstring& s, float maxW);
|
||||
|
||||
// A layered-window canvas: draws into a 32bpp PARGB DIB section and composes
|
||||
// it with UpdateLayeredWindow (per-pixel alpha — the Win32 equivalent of
|
||||
// Qt::FramelessWindowHint + WA_TranslucentBackground).
|
||||
class LayeredCanvas {
|
||||
public:
|
||||
LayeredCanvas() = default;
|
||||
~LayeredCanvas();
|
||||
LayeredCanvas(const LayeredCanvas&) = delete;
|
||||
LayeredCanvas& operator=(const LayeredCanvas&) = delete;
|
||||
|
||||
bool Resize(int w, int h); // (re)allocate the ARGB surface
|
||||
void Clear(); // fill fully transparent
|
||||
Gdiplus::Graphics& g(); // draw here after Clear()
|
||||
|
||||
// Composites the surface onto `hwnd` at its current position with an
|
||||
// extra whole-window constant alpha (0..255) used for fades.
|
||||
bool Present(HWND hwnd, BYTE constAlpha = 255);
|
||||
|
||||
int width() const { return w_; }
|
||||
int height() const { return h_; }
|
||||
|
||||
private:
|
||||
void Free();
|
||||
|
||||
HDC dc_ = nullptr;
|
||||
HBITMAP bmp_ = nullptr;
|
||||
void* bits_ = nullptr;
|
||||
Gdiplus::Bitmap* wrap_ = nullptr; // GDI+ view over the DIB memory
|
||||
Gdiplus::Graphics* gfx_ = nullptr;
|
||||
int w_ = 0, h_ = 0;
|
||||
};
|
||||
|
||||
// Random alphanumeric identifier (first char a letter), used for the
|
||||
// non-constant Win32 window titles that defeat fixed-string scanners.
|
||||
std::wstring RandomIdent(int minLen, int maxLen);
|
||||
|
||||
// Appends a line to %TEMP%\openzen-loader.log (startup diagnostics).
|
||||
void LogLine(const std::wstring& msg);
|
||||
|
||||
} // namespace ui
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "loader.h"
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
struct Search {
|
||||
DWORD pid;
|
||||
WindowInfo best;
|
||||
};
|
||||
|
||||
BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lp) {
|
||||
Search* s = reinterpret_cast<Search*>(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.title.size()) {
|
||||
wchar_t cls[256] = {0};
|
||||
GetClassNameW(hwnd, cls, 256);
|
||||
s->best.title = std::move(title);
|
||||
s->best.class_name = cls;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
WindowInfo window_info_for(DWORD pid) {
|
||||
Search s{pid, {}};
|
||||
EnumWindows(enum_proc, reinterpret_cast<LPARAM>(&s));
|
||||
return s.best;
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
Reference in new issue
屏蔽一个用户