No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Disable autocrlf on generated files, they always generate with LF
|
||||
# Add any extra files or paths here to make git stop saying they
|
||||
# are changed when only line endings change.
|
||||
src/generated/**/.cache/cache text eol=lf
|
||||
src/generated/**/*.json text eol=lf
|
||||
@@ -0,0 +1,58 @@
|
||||
name: 功能异常 (Bug)
|
||||
description: 提交一个功能不按预期工作的问题
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你抽出时间反馈 Bug。请尽量详细地填写以下内容,以便我们快速定位与修复。
|
||||
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: Bug 表现
|
||||
description: 实际发生了什么?与预期有何不同?
|
||||
placeholder: 例如:点击 ClickGUI 中的 KillAura 按钮后,模块未被启用,且控制台无任何输出。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 说明
|
||||
description: 关于该问题的补充说明、上下文、你认为可能的原因等。
|
||||
placeholder: 例如:仅在切换世界后第一次启用模块时出现,重启游戏后正常。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图
|
||||
description: 请粘贴或拖入相关截图 / 录屏。若涉及 UI 错位,务必附图。
|
||||
placeholder: 直接将图片拖到此处即可上传。
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请提供清晰、可被他人按步骤复现的操作流程。
|
||||
placeholder: |
|
||||
1. 启动客户端并进入任意服务器
|
||||
2. 打开 ClickGUI (默认 RShift)
|
||||
3. 点击 Combat -> KillAura
|
||||
4. 观察到 ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-fix
|
||||
attributes:
|
||||
label: 应当被如何修复
|
||||
description: 你认为这个问题应该怎样被修复?如果有相关代码位置 / 思路也请一并写出。
|
||||
placeholder: 例如:`ModuleManager#toggle` 中似乎未触发 `onEnable` 回调,应在切换状态后调用对应的生命周期方法。
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,38 @@
|
||||
name: 崩溃 (Crash)
|
||||
description: 提交一个导致客户端 / 游戏崩溃的问题
|
||||
title: "[Crash] "
|
||||
labels: ["crash"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你反馈崩溃问题。**请务必附上完整的崩溃日志**,缺失日志的崩溃 issue 几乎无法被定位。
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: 日志
|
||||
description: |
|
||||
请粘贴完整的崩溃日志 (crash-report) 或 `logs/latest.log` 中的相关片段。
|
||||
若日志过长,推荐使用 GitHub Gist / [mclo.gs](https://mclo.gs) 上传后贴出链接。
|
||||
placeholder: |
|
||||
```
|
||||
---- Minecraft Crash Report ----
|
||||
// ...
|
||||
```
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 触发崩溃所需的操作步骤。越具体越好,包括是否是必现、概率多大。
|
||||
placeholder: |
|
||||
1. 进入单人世界
|
||||
2. 启用 Scaffold 模块
|
||||
3. 持续向前移动约 5 秒
|
||||
4. 客户端崩溃 (100% 复现)
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,18 @@
|
||||
name: 建议 (Suggest)
|
||||
description: 提出一个新功能、改进或想法
|
||||
title: "[Suggest] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你的建议!请尽量清晰地描述你希望看到的改动以及它的价值。
|
||||
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: 建议内容
|
||||
description: 你希望增加 / 修改什么功能?它解决了什么问题?
|
||||
placeholder: 例如:希望 ClickGUI 支持配置面板宽度,目前在 1080p 下默认宽度过窄。
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,213 @@
|
||||
name: Build Loader
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# Only build when something that actually affects the output changed.
|
||||
# README / docs / issue templates / .gitignore / .claude/ get to skip.
|
||||
# Use workflow_dispatch (below) to force a run for anything else.
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'native/**'
|
||||
- 'build.gradle'
|
||||
- 'settings.gradle'
|
||||
- 'gradle.properties'
|
||||
- 'gradle/wrapper/**'
|
||||
- 'gradlew'
|
||||
- 'gradlew.bat'
|
||||
- '.github/workflows/build-loader.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
# contents: write so the [Release] commit-marker path can create a
|
||||
# GitHub Release and upload the built artifacts.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 60
|
||||
|
||||
# Honour a [SKIP CI] marker (case-insensitive — GitHub's contains() is
|
||||
# case-insensitive for string operands) anywhere in the head commit
|
||||
# message. workflow_dispatch always runs since head_commit is null
|
||||
# there and the !contains() short-circuits to true.
|
||||
if: ${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[SKIP CI]') }}
|
||||
|
||||
env:
|
||||
# Opt every JavaScript action into the Node.js 24 runtime ahead of the
|
||||
# 2026-06-02 forced switchover. The v4 / v1 pins below all still ship
|
||||
# a Node 20 binary in their action.yml, and GitHub deprecated Node 20
|
||||
# on 2025-09-19; setting this flag makes the runner execute them under
|
||||
# Node 24 regardless, which silences the deprecation warning and
|
||||
# de-risks the upcoming default flip.
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve git revision
|
||||
id: rev
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sha = git rev-parse --short=7 HEAD
|
||||
"sha=$sha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
Write-Host "Build revision: $sha"
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup MSVC (x64)
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: x64
|
||||
|
||||
- name: Install UPX
|
||||
shell: pwsh
|
||||
run: choco install upx -y --no-progress
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Build (clean dll upxCompress)
|
||||
shell: pwsh
|
||||
env:
|
||||
OPENZEN_BUILD_REVISION: ${{ steps.rev.outputs.sha }}
|
||||
run: |
|
||||
Write-Host "JAVA_HOME = $env:JAVA_HOME"
|
||||
Write-Host "OPENZEN_BUILD_REV = $env:OPENZEN_BUILD_REVISION"
|
||||
.\gradlew.bat --no-daemon clean dll upxCompress
|
||||
|
||||
# Rename the two distributable artifacts so their final filenames carry
|
||||
# the build sha. We rename rather than re-publish at build time so the
|
||||
# local `./gradlew dll` workflow keeps producing stable filenames.
|
||||
- name: Stage release artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sha = "${{ steps.rev.outputs.sha }}"
|
||||
$exeSrc = "build\dist\OpenZenLoader.exe"
|
||||
$jarSrc = "build\libs\hey-1.0.jar"
|
||||
# The class-name obfuscator emits a fresh, random old->new mapping on every
|
||||
# build; rename-mapping.txt is the ONLY way to de-obfuscate a stack trace, so
|
||||
# ship it with the artifacts/release.
|
||||
$mapSrc = "build\rename-mapping.txt"
|
||||
if (-not (Test-Path $exeSrc)) { throw "missing $exeSrc" }
|
||||
if (-not (Test-Path $jarSrc)) { throw "missing $jarSrc" }
|
||||
if (-not (Test-Path $mapSrc)) { throw "missing $mapSrc" }
|
||||
|
||||
$release = "build\release"
|
||||
New-Item -ItemType Directory -Force -Path $release | Out-Null
|
||||
$exeDst = Join-Path $release "OpenZenLoader-$sha.exe"
|
||||
$jarDst = Join-Path $release "OpenZen-$sha.jar"
|
||||
$mapDst = Join-Path $release "OpenZen-$sha-mapping.txt"
|
||||
Copy-Item -Force $exeSrc $exeDst
|
||||
Copy-Item -Force $jarSrc $jarDst
|
||||
Copy-Item -Force $mapSrc $mapDst
|
||||
|
||||
$exeSz = (Get-Item $exeDst).Length
|
||||
$jarSz = (Get-Item $jarDst).Length
|
||||
Write-Host ("OpenZenLoader-{0}.exe : {1:N0} bytes ({2:N2} MB)" -f $sha, $exeSz, ($exeSz/1MB))
|
||||
Write-Host ("OpenZen-{0}.jar : {1:N0} bytes ({2:N2} MB)" -f $sha, $jarSz, ($jarSz/1MB))
|
||||
Write-Host ("OpenZen-{0}-mapping.txt : {1:N0} bytes" -f $sha, (Get-Item $mapDst).Length)
|
||||
|
||||
# NOTE: actions/upload-artifact always wraps its content in a zip; that
|
||||
# is a platform limitation we cannot disable. By giving each artifact a
|
||||
# single file whose name already encodes the sha, the download is
|
||||
# OpenZenLoader-<sha>.exe.zip / OpenZen-<sha>.jar.zip, each containing
|
||||
# just the named file (no nested directory). For raw exe/jar downloads
|
||||
# without the zip wrapper, attach to a GitHub Release instead.
|
||||
- name: Upload OpenZenLoader exe
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OpenZenLoader-${{ steps.rev.outputs.sha }}.exe
|
||||
path: build/release/OpenZenLoader-${{ steps.rev.outputs.sha }}.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenZen jar
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OpenZen-${{ steps.rev.outputs.sha }}.jar
|
||||
path: build/release/OpenZen-${{ steps.rev.outputs.sha }}.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload de-obfuscation mapping
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OpenZen-${{ steps.rev.outputs.sha }}-mapping.txt
|
||||
path: build/release/OpenZen-${{ steps.rev.outputs.sha }}-mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# ===== Optional GitHub Release publish =====
|
||||
# If the HEAD commit message contains the literal marker "[Release]",
|
||||
# cut a GitHub Release tagged build-<sha> and attach the exe + jar.
|
||||
# Without the marker, this step is skipped — every push still produces
|
||||
# the Actions artifacts above, only tagged releases are gated.
|
||||
- name: Detect [Release] marker
|
||||
id: relmark
|
||||
shell: pwsh
|
||||
run: |
|
||||
$msg = (git log -1 --pretty=%B HEAD | Out-String)
|
||||
# Case-insensitive match so [release], [Release], [RELEASE] all
|
||||
# qualify; .Contains in .NET is case-sensitive by default.
|
||||
$isRelease = $msg.IndexOf("[Release]", [System.StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
"is_release=$($isRelease.ToString().ToLower())" |
|
||||
Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
Write-Host "HEAD commit message:"
|
||||
Write-Host $msg
|
||||
Write-Host "[Release] marker present: $isRelease"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: steps.relmark.outputs.is_release == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SHA: ${{ steps.rev.outputs.sha }}
|
||||
run: |
|
||||
$tag = "build-$env:SHA"
|
||||
$title = "Build $env:SHA"
|
||||
# Write notes via a file so quoting / [brackets] / newlines in the
|
||||
# commit message can't corrupt the gh command line.
|
||||
$notes = "release-notes.md"
|
||||
# Prepend a PRE-BUILT warning to the release body: these artifacts all share
|
||||
# one fixed obfuscation mapping, so an anti-cheat class-name blacklist can
|
||||
# target them. Tell users to self-compile for unique, per-build random names.
|
||||
# Build the banner as a string array (one line each) to avoid PowerShell
|
||||
# here-string column-0 terminator issues inside a YAML block scalar.
|
||||
$warn = @(
|
||||
'> ⚠️ **这是预构建版本(PRE-BUILT)**'
|
||||
'>'
|
||||
'> 本 Release 里的 `OpenZenLoader.exe` / `OpenZen-*.jar` 是 GitHub Actions 编译的成品,**所有人下载到的是同一套混淆类名**。这套固定的名字随时可能被反作弊(如布吉岛)收录进**类名黑名单**而失效。'
|
||||
'>'
|
||||
'> 想要一套**独一无二、别人都不知道**的类名,请**自己编译**(每次构建都会生成全新随机类名):'
|
||||
'> - **Fork 本仓库**,在你自己的 GitHub Actions 里跑 `Build Loader` 工作流,下载你自己的 artifact;**或**'
|
||||
'> - **clone 到本地**自己 `gradlew jar` / `gradlew dll`。'
|
||||
'>'
|
||||
'> 详见仓库 README 的「编译时类名混淆」。`OpenZen-*-mapping.txt` 是本次构建的反混淆映射(每次构建都不同)。'
|
||||
''
|
||||
'---'
|
||||
''
|
||||
)
|
||||
$warn | Out-File -FilePath $notes -Encoding utf8
|
||||
git log -1 --pretty=%B HEAD | Out-File -FilePath $notes -Encoding utf8 -Append
|
||||
gh release create $tag `
|
||||
--title $title `
|
||||
--notes-file $notes `
|
||||
"build/release/OpenZenLoader-$env:SHA.exe" `
|
||||
"build/release/OpenZen-$env:SHA.jar" `
|
||||
"build/release/OpenZen-$env:SHA-mapping.txt"
|
||||
@@ -0,0 +1,37 @@
|
||||
# eclipse
|
||||
bin
|
||||
*.launch
|
||||
.settings
|
||||
.metadata
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# idea
|
||||
out
|
||||
*.ipr
|
||||
*.iws
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# gradle
|
||||
build
|
||||
.gradle
|
||||
|
||||
# other
|
||||
eclipse
|
||||
run
|
||||
|
||||
# Files from Forge MDK
|
||||
forge*changelog.txt
|
||||
|
||||
# Native DLL build artifacts (CMake out-of-source build + staged jar)
|
||||
native/build/
|
||||
native/zen.jar
|
||||
native/vcpkg_installed/
|
||||
|
||||
# Generated at build time by ext.obfuscateJar (holds the obfuscated bridge FQCN)
|
||||
native/dll/src/generated_names.h
|
||||
|
||||
# Local build artifacts
|
||||
build_opencode.log
|
||||
gradle.pid
|
||||
@@ -0,0 +1,44 @@
|
||||
# AGENTS.md
|
||||
|
||||
Deobfuscated source of the *Zen* Minecraft client. Target: **MC 1.20.1 + Forge 47.4.20, Java 17**. Background (Chinese): `README.md`.
|
||||
|
||||
## What this is — critical
|
||||
|
||||
- **NOT a Forge mod.** Never test by dropping the jar in `.minecraft/mods/`. It ships as:
|
||||
1. a **Java agent jar** (`-javaagent:` JVM arg), or
|
||||
2. a **Windows injector EXE** (`OpenZenLoader.exe`) that maps a DLL into a running `javaw.exe`.
|
||||
- Entrypoints:
|
||||
- Agent: `asm.patchify.loader.PatchAgent` (`premain`; manifest Premain/Agent-Class) applies ASM patches registered in `PatchRegistry`.
|
||||
- Mod-side: `shit.zen.ZenClient` — `@Mod("hey")`; mod id is `hey`, jar is `build/libs/hey-1.0.jar`.
|
||||
- DLL path: `shit.zen.dll.DllBootstrap` / `GameLoaderBridge`, loaded by C++ under `native/`.
|
||||
- Layout: `asm.patchify.*` = agent bootstrap + patch annotations/transformer machinery; `shit.zen.**` = the client (modules/gui/commands/events/etc.); `native/` = CMake C++ (`native/dll` + `native/loader`); `mapping/zen.mapping` = mapping of the ORIGINAL obfuscated Zen jar.
|
||||
- Where code goes: vanilla-MC behavior changes live in `shit.zen.patch` (`*Patch.java` using `@Patch`/`@Inject`/`@Overwrite`/`@WrapInvoke` from `asm.patchify.annotation`, applied to mojmap names); client features go in `shit.zen.modules`/`gui`/`command` etc.
|
||||
|
||||
## Build-time class-name obfuscation
|
||||
|
||||
- Every build renames all `shit.zen.*` / `asm.patchify.*` classes to random 16-char names in one random package (auto-runs after `reobfJar`; see `ext.obfuscateJar` in `build.gradle`).
|
||||
- **Class names only** — method/field names are intentionally preserved (JNI lookups, reflection, GSON `@SerializedName`). Don't "fix" this.
|
||||
- Never hardcode OpenZen class names across a build boundary. The manifest Premain-Class, `Class.forName` string literals, and the native side (`native/dll/src/generated_names.h`) are rewritten/propagated by the build automatically — route new bootstrap references through that mechanism.
|
||||
- `build/rename-mapping.txt` is the ONLY way to de-obfuscate runtime stack traces and differs every build. Not to be confused with `mapping/zen.mapping` (original Zen).
|
||||
- `native/dll/src/generated_names.h` is auto-generated and gitignored — never edit or commit it.
|
||||
- Because class names are random, logging relies on fixed message strings (`bootstrap.start`, `bridge.load`, `agent attached`), not logger/class names. Native log: `%TEMP%\openzen-<pid>-<ticks>.log` — one file per injection attempt (a shared `openzen.log` was broken: the first injected process held it open forever, so every later injection's `log::init` hit ERROR_SHARING_VIOLATION and logged nothing).
|
||||
|
||||
## Commands
|
||||
|
||||
- JDK 17 required; `JAVA_HOME` must point at it. Run Gradle itself on JDK 17 — the buildscript's ASM 9.6 cannot read class files of newer JDKs (Java 21+).
|
||||
- `.\gradlew.bat jar` — agent jar at `build/libs/hey-1.0.jar` (reobf + class renaming run automatically).
|
||||
- `.\gradlew.bat runClient0` — builds the jar and launches a dev MC client with `-javaagent` wired up. This is the supported way to test in-dev. DevAuth is a `runtimeOnly` dep (Microsoft login in dev); working dir is `run/`.
|
||||
- `.\gradlew.bat dll` — `build/dist/OpenZenLoader.exe`. Requires MSVC (VS 2019/2022, C++ workload), CMake (auto-found via PATH/vswhere/standard paths), and `JAVA_HOME` = JDK 17 (jni.h/jvmti.h). Windows-only. `upxCompress` is optional; it skips with a warning if UPX is absent.
|
||||
- `.\gradlew.bat clean` also wipes `native/build/` and `native/zen.jar` (`cleanNative`).
|
||||
- `gradle.properties` sets `org.gradle.daemon=false` and a 3G heap (MC decompilation). First build downloads Forge mappings — expect minutes.
|
||||
- **No test suite, linter, or formatter exists.** Verification = `gradlew jar` compiling; behavior via `gradlew runClient0`.
|
||||
|
||||
## CI (`.github/workflows/build-loader.yml`)
|
||||
|
||||
- Push to `master`, path-filtered (src/native/gradle files; docs-only changes skip). windows-2022 runner runs `clean dll upxCompress`.
|
||||
- Commit-message markers: `[Release]` (case-insensitive) cuts a GitHub Release `build-<sha>` with exe + jar + mapping; `[SKIP CI]` skips the run.
|
||||
- CI passes the resolved sha as `OPENZEN_BUILD_REVISION`.
|
||||
|
||||
## Local run helper
|
||||
|
||||
- `launch-mc-agent.ps1` launches MC 1.20.1 Forge with the agent via `-javaagent` (offline auth). Paths inside are machine-specific hardcodes — edit before use elsewhere.
|
||||
@@ -0,0 +1,311 @@
|
||||
# Open Zen
|
||||
|
||||
> 由于老群被精神马来西亚人的小狗疯狂举报,因此无法与您继续互动。欢迎加新群 660304532 讨论本项目。
|
||||
|
||||
> 当您发现有不可用的功能时,欢迎开 Issues 描述或直接提交 Pull Request,我们将不胜感激!
|
||||
|
||||
**Open Zen** 是 *Zen* Minecraft 客户端的反混淆源码版本,几乎是由 [Claude](https://claude.com/) 协助逆向得到。目标版本为 **Minecraft 1.20.1 + Forge 47.4.20**。
|
||||
|
||||
原始 Jar 经过完整混淆:类/字段/方法重命名、控制流扁平化。我使用 Opus 4.7 对其进行了反混淆,并结合 [Enigma MCP](https://github.com/Margele/Enigma-MCP) 和 Sonnet 4.6 对其类/字段/方法重命名进行猜测,最后将其还原为可读的 Java。最终产物是一个可以直接用 Gradle 构建的工程,而不是一个二进制 blob。
|
||||
|
||||
> ⚠️ 本仓库**仅供学习与研究目的发布** —— 用于研究客户端侧游戏改造、ASM 字节码补丁和混淆/反混淆技术。在你不拥有的服务器上使用作弊客户端违反绝大多数服务器规则,请自行承担后果。
|
||||
|
||||
## 精神马来西亚人穿女装黑丝跳舞视频
|
||||

|
||||
|
||||
## 精神马来西亚人最新力作
|
||||
[点我跳转](https://docs.google.com/spreadsheets/d/1KZotYDgOnj8QKRSoSIT9otVLlcJ-wfVdFIF4R00Rrc8)
|
||||
|
||||
## 许可
|
||||
原始混淆字节码未授予任何许可。本仓库中的反混淆产物、构建脚本与文档**仅供研究与学习使用**。如果你是 Zen 的原作者并希望本仓库下架或重新授权,请提 Issues。虽然提了也不会搭理你。
|
||||
|
||||
## 截图
|
||||

|
||||
|
||||
## 精神马来西亚人
|
||||
或许是由于Zen的作者可能由于常年惨遭家暴,亦可能是由于常年沉迷于米哈游大作导致大脑退化完成义务教育后无法进行进一步的大脑升级。精神马来西亚人不得不前往马来西亚,以进一步大脑升级为高中毕业学历。在精神马来西亚人抵达马来西亚后,精神马来人似乎找到了自己的归宿。
|
||||
|
||||

|
||||
|
||||
自此,精神马来人正式成为精神马来人。开始称中国人“支那猪”,称中国[“支那”](https://zh.wikipedia.org/wiki/%E6%94%AF%E9%82%A3)。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
除此之外,精神马来人还会发表更多奇异搞笑言论。当你购买精神马来西亚人的外挂后,你必须要阅读#rules后才可以使用其外挂,其中包括“承认台湾是一个国家”、“承认新疆、西藏、香港、澳门同样都是独立国家”等奇异搞笑言论。因此笔者很难想象其外挂用户的政治立场。
|
||||
|
||||

|
||||
|
||||
除此之外,精神马来西亚人称惨绝人寰的[南京大屠杀](https://zh.wikipedia.org/wiki/%E5%8D%97%E4%BA%AC%E5%A4%A7%E5%B1%A0%E6%AE%BA)事件**晦气**。
|
||||
|
||||

|
||||
|
||||
## 开挂死妈
|
||||

|
||||
|
||||
精神马来西亚人认为,笔者在游玩《三角洲行动》时使用了外挂程序是疑似右手残疾的表现,正确的做法是练习枪法。因此使用本项目在《我的世界》中作弊可能会导致右手残疾,在使用本项目进行作弊前,请确认您的右手没有残疾!本项目不会对您的右手残疾付任何责任。
|
||||
|
||||
如果您在使用本项目时出现了疑似右手残疾的症状(如右手无力等),请及时关闭键盘声音以避免自己的生物爹对自己进行家暴行为。
|
||||
|
||||
[不是我咋掉线了操](./img/不是我咋掉线了操.mp4)
|
||||
|
||||
## 我有抑郁症
|
||||
众所周知,精神马来西亚人患有严重的精神疾病。结合此前精神马来人对其他亲朋好友的倾诉,笔者得知精神马来西亚人曾在群直播自己使用作弊客户端游玩《我的世界》游戏。但是突然下播,在长达半个小时的等待时间后,精神马来西亚人称自己由于键盘声音过大而惨遭家暴。
|
||||
|
||||

|
||||
|
||||
因此精神马来西亚人长期通过自残、过量使用药物等行为缓解自己长期惨遭家暴的事实。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
或许是出自自卑, 精神马来西亚人在公开时称嗑药是跟风行为。对于精神马来人对过量使用药物的态度,笔者暂且蒙在鼓里。
|
||||
|
||||
## 大孝子
|
||||
可能由于常年的家暴,导致精神马来西亚人的认知出现了错乱。又或许是长期多次的家暴导致精神马来西亚人患上了[创伤后应激障碍](https://zh.wikipedia.org/wiki/%E5%89%B5%E5%82%B7%E5%BE%8C%E5%A3%93%E5%8A%9B%E7%97%87),精神马来西亚人认为自己**滚刀爹妈**。笔者尚不明确精神马来西亚人所述的滚刀爹妈是何意味,但是笔者希望精神马来西亚人早日康复。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 发送低保
|
||||

|
||||
|
||||
当精神马来西亚人急眼时,将会自动查询你爹妈的户籍并且强制向你爹妈发送两份低保。
|
||||
|
||||
尽管[关于印发《山东省最低生活保障管理办法》的通知](http://mzt.shandong.gov.cn/art/2021/9/30/art_15335_10291529.html)明确规定了:
|
||||
|
||||
```
|
||||
第二十三条 家庭财产状况有下列情形之一的,原则上不纳入低保范围:
|
||||
|
||||
(一)人均金融资产超过当地年低保标准2倍的;
|
||||
(二)拥有机动车辆(普通二轮和三轮摩托车、残疾人用于功能型补偿代步的机动车辆除外)、船舶、大型农机具的;
|
||||
(三)拥有两套及以上住房且住房总面积超过当地住房保障标准面积2倍,或者申请低保之前1年内以及享受低保期间购买超过当地住房保障标准面积商品房的;申请低保之前1年内或者享受低保期间,兴建、购买非居住用房或者高标准装修住房的;
|
||||
(四)具有投资行为且人均投资数额超过当地年低保标准2倍的;
|
||||
(五)雇佣他人从事经营性活动的;
|
||||
(六)实际生活水平明显高于当地低保标准的。
|
||||
|
||||
设区的市可根据各自实际和财力条件,对家庭财产状况规定进行细化和调整,增加的支出由当地筹集安排。
|
||||
```
|
||||
|
||||

|
||||
|
||||
但仅笔者一人,2026年个人所得税申报仅有约四十万人民币,笔者家庭明显符合不纳入低保范围的条件。笔者暂且不清楚精神马来西亚人向笔者全家发送低保的动机,可能是因为精神马来人惨遭家暴精神错乱致使其认为拥有低保是一件非常令人羞耻的事情。笔者建议精神马来西亚人早日纠正错误想法。
|
||||
|
||||
## 后门
|
||||
> 警告!在阅读以下内容时,您可能会感到不适!如有不适,请及时关闭本页面,以避免自己笑出声音而导致惨遭生物爹家暴。
|
||||
|
||||
我们在逆向时发现原版Zen存在大量后门,例如上报QQ、屏幕截图、扫描文件、上传文件、远程执行命令等。因此我们**不推荐**任何用户继续使用原版Zen,除非你愿意现在把你身上的衣服脱掉然后去本地人最多的广场裸舞,然后把自己裸舞的视频发送到Zen的群内。
|
||||
|
||||
当Zen被注入后,会自动触发截图并上传至服务器。精神马来西亚人回应如下:
|
||||
|
||||

|
||||
|
||||
由于精神马来西亚人从小父母双亡,无父无母的精神马来西亚人自幼脑回路不正常。他认为虽然自己没有说自己的外挂会截图,但是由于自己截图,并没有遭到用户反对,所以所有用户都心甘情愿被截图**全屏**并上传到其服务器上。当然不排除所有Zen客户端用户都喜欢把身上的衣服脱掉然后去本地人最多的广场裸舞的可能性。
|
||||
|
||||

|
||||
|
||||
对此,笔者综合精神马来西亚人由于半夜玩电脑惨遭自己生物爹家暴的事实猜测:精神马来西亚人的生物爹和生物妈可能对精神马来西亚人的控制欲极强,因此精神马来西亚人的生活空间内可能存在十万甚至九万个摄像头,对精神马来西亚人的生活进行了无孔不入的监控。因此,精神马来西亚人在拉屎、自慰、睡觉、上课时都时时刻刻被监控,所以自然认为截图用户是正常且合理的行为。
|
||||
|
||||
笔者在此提醒:对用户的电脑进行无提醒、未通知用户的全屏幕截图,是不合理的行为。建议精神马来人端正自己对这个世界的认知,从自己过往被家暴经历中走出来,祝你早日康复!
|
||||
|
||||
### 分析
|
||||
当Zen启动时,会自动调用 `iIiIiIiIIIiIiI/Ʊ Đ()Ljava/awt/image/BufferedImage` ([Mapping](./mapping/zen.mapping#L140)),可能由于精神马来西亚人自知是后门,因此精神马来西亚人将此方法严防死守,惨遭没有逼卵子用的Native混淆。
|
||||
|
||||
以下是对该方法的Trace。
|
||||
|
||||

|
||||
|
||||
可见,此方法调用了 `java/awt/Robot;createScreenCapture(Ljava/awt/Rectangle)`,会将用户的**全屏**截图后返回。
|
||||
|
||||
继续向下追踪,发现其新建了 `iIiIiIiIIIiIiI/ɿ` (`CPacketSystemInfo`) ([Mapping](./mapping/zen.mapping#L2552)) 对象,我们对该类反编译,发现精神马来西亚人妈妈死掉了所以忘记删除Lombok自动生成的`@ToString`方法,因此惨遭Claude还原。
|
||||
|
||||

|
||||
|
||||
此包会上传用户处理器信息、模组列表、虚拟机参数、系统名称、上报截图等信息,但笔者认为除截图外,其他信息收集在**提前告知用户的前提下**是合理的,因此并无不妥。虽然精神马来西亚人没有提前告知用户。
|
||||
|
||||
随后,笔者继续分析。由于该类继承了 `iIiIiIiIIIiIiI/ɰ` (`Packet`) ([Mapping](./mapping/zen.mapping#L2459)),我们分析了所有该类的子类。
|
||||
|
||||
遗憾的是,其他类由于没有添加 Lombok 标识,我们不得不通过其他方式 Trace 这些类的具体用途。经过我们不懈努力的调试和追踪,我们还原出了我们认为可疑的部分行为。
|
||||
|
||||
- 远程命令执行 `iIiIiIiIIIiIiI/ʔ`
|
||||
- 远程文件下发 `iIiIiIiIIIiIiI/ʏ`
|
||||
- 远程文件浏览 `iIiIiIiIIIiIiI/ʑ`
|
||||
|
||||
*以上不是全部*
|
||||
|
||||

|
||||
|
||||
对于这些后门,精神马来西亚人作此解释。
|
||||
|
||||

|
||||
|
||||
精神马来西亚人称这些后门全部都是由**夏天233**制造,并非自己。并且这些后门并没有实现,所以可能是由于精神马来西亚人产生幻觉导致笔者抓到了Trace。而且并不能解释同是一套Network系统,为什么上传截图包实现了但其他方法没有实现。
|
||||
|
||||
其后其在[视频](https://www.bilibili.com/video/BV147L86TEEZ)中表示,是服务器在迁移时没有实现,而不是客户端没有实现。同时,精神马来西亚人在视频中表示*没有功能*,但是在QQ群中表示*是夏天233写的*。
|
||||
|
||||

|
||||
|
||||
由于精神马来西亚人嘴硬,所以到底具体有没有实现,笔者暂且蒙古。
|
||||
|
||||
### 父子相爱相杀
|
||||
|
||||

|
||||
|
||||
在很久之前,作为知名野狗的许锦良曾对精神马来西亚人进行过攻击:许锦良认为精神马来西亚人是他儿子,去日本是为了成为慰安妇,抚平自己被家暴的过去。
|
||||
|
||||

|
||||
|
||||
但很明显,在本项目发布后,许锦良对OpenZen交流群创下了高达十进十出的历史记录。笔者在此猜测,精神马来西亚人曾因为自己半夜玩电脑由于敲键盘声音过大惨遭家暴的事情中迟迟无法走出阴影,因此自小时便缺失来自生物爹的父爱。而许锦良称精神马来西亚人为儿子,因此刚好补上了自己缺失的父爱这一块,私下便偷偷称许锦良为自己的父亲。自始,二人幸终。
|
||||
|
||||
笔者在惨遭许锦良十进十出狗叫时,认为许锦良可能已经完成[前脑叶白质切除术](https://zh.wikipedia.org/wiki/%E8%84%91%E7%99%BD%E8%B4%A8%E5%88%87%E9%99%A4%E6%9C%AF),许锦良坚持认为两张照片是同一个人,对此许锦良掏出了以下证据:
|
||||
|
||||

|
||||
|
||||
由此可见,许锦良并没有思考的能力,结合自己亲儿子精神马来西亚人患有多种精神疾病的事实与精神马来西亚人认为Telegram查询机器人的事实,证实了笔者在前提到的前脑叶白质切除术。笔者在此希望许锦良父子能够早日康复。
|
||||
|
||||

|
||||
|
||||
但精神马来西亚人在QQ群中指出,欣欣使用过外挂后门远程读取他人文件。不知作为德州骡子的许锦良见自己的亲生儿子如此指认自己作何感想。
|
||||
|
||||
## 原始 Jar + Mapping
|
||||
[原始Jar](./mapping/zen-orignial.jar)
|
||||
[Mapping](./mapping/zen.mapping)
|
||||
|
||||
需要说明的是,部分喜欢裸舞的忠实Zen用户认为本源码逆向自比较旧的Zen版本。可能是因为这部分用户的脑容量只允许自己导入其他配置,因此不认识Zen的老旧UI。因此必须要说明,此源码使用的原始Jar截止至2026年5月21日是最新的。
|
||||
|
||||
## 细节
|
||||
经过Opus 4.7长达18秒的分析,Opus认为所有的类由惨遭魔改的Zelix KlassMaster混淆。除了Zelix的Invoke Dynamic和String Encryption外,还有部分未参与任何计算的 Integer / Long 变量花指令代码和仅在部分方法中出现的 Flow 混淆。
|
||||
|
||||
其中大部分类都可以经过小修小补的现有Zelix反混淆器完成,关键部分的`cinit`被Native保护,导致在Java层中没有对应的Master Key可以对Invoke Dynamic和String反混淆。但可能由于精神马来西亚人的脑袋在马来西亚骑摩托被其他车创飞导致脑溢血,即使你没有通过客户端认证也可以完整加载Native并对Class进行注册。因此我们完整的还原了所有类的Invoke Dynamic和String混淆。
|
||||
|
||||
其他的混淆经过Opus长达30秒的分析,顺利写出了反混淆器。 但被Rename后的代码几乎不可读,因此我用Opus 4.7制作了[Enigma MCP](https://github.com/Margele/Enigma-MCP),接入Sonnet 4.6对其参照部分客户端进行了反混淆。
|
||||
|
||||
再使用Opus 4.7对本项目经过长达6小时的修复和少量的人工修复,便得到了这份源码。
|
||||
|
||||
## 抄袭
|
||||
此项目大部分功能模块几乎全部抄袭自Naven客户端,具体详见以下分析。
|
||||
|
||||
[详细分析](./paste/README.md)
|
||||
|
||||
## 状态与注意事项
|
||||
- 这是**尽力而为的反混淆结果**,部分符号是根据上下文重建的,可能与原作者的命名意图不一致。
|
||||
|
||||
## 构建
|
||||
|
||||
OpenZen 支持两种交付形式:**Java Agent jar**(挂到 Minecraft JVM 启动参数里)和 **热注入器 (单文件 EXE,内嵌 DLL)**。Agent 路径只要 JDK,注入器路径还需要 MSVC 工具链。
|
||||
|
||||
> **本项目不能作为 Forge mod 启动。** `mods/` 加载路径不被支持,不要把 jar 丢进 `.minecraft/mods/`。
|
||||
|
||||
### 编译时类名混淆(重要)
|
||||
|
||||
每次构建,OpenZen 会**自动把所有自有类(`shit.zen.*` / `asm.patchify.*`)重命名为随机的 16 位名字**——包名和类名都随机,**每次构建都不一样**、互不重复,原始类名/包名一律不保留(连日志里残留的类名字符串也清理掉了)。引导链(Agent 入口、DLL 加载、`Class.forName`)会在构建时自动联动到新名字,无需手工处理。两种交付形式(jar / 注入器)都已混淆。
|
||||
|
||||
这是为了对抗按**类名黑名单**工作的反作弊(见下方[常见问题](#布吉岛反作弊绕过))。正因为名字每次构建随机:
|
||||
|
||||
> ⚠️ **从 GitHub Actions / Release 下载到的是预编译版本,所有人拿到的是同一套混淆名**——这套固定的名字随时可能被反作弊收录进黑名单。想要一套**别人都不知道、独一无二**的类名,请**自己编译**:
|
||||
> - **Fork 本仓库**,在你自己的 GitHub Actions 里跑一次构建(每次运行都生成全新随机名),下载你自己的 artifact;**或**
|
||||
> - **clone 到本地**自己 `gradlew jar` / `gradlew dll`(每次本地构建同样是全新随机名)。
|
||||
|
||||
每次构建的"旧名 → 新名"映射写在 `build/rename-mapping.txt`(CI 也会把它作为 artifact 上传、并附到 Release),这是反混淆崩溃日志的**唯一**依据。**注意它每次构建都不同,务必和对应产物一起保存。**
|
||||
|
||||
实现细节见 `build.gradle` 的 `ext.obfuscateJar`:用项目自带的 ASM 在 ForgeGradle `reobf` 之后对产出 jar 做 `ClassRemapper` 重命名,**只改类名、不动方法/字段名**(避免破坏反射、JNI、`@SerializedName` 等)。
|
||||
|
||||
### 共同前置
|
||||
|
||||
- **JDK 17**(推荐 Microsoft Build of OpenJDK / Temurin / Azul Zulu 任一)。
|
||||
- 必须设置 `JAVA_HOME` 环境变量指向该 JDK 安装目录(PowerShell 验证:`echo $env:JAVA_HOME`)。
|
||||
- 仓库根目录用 `gradlew.bat` 即可,**不需要**单独安装 Gradle。
|
||||
- **可选:UPX** —— 仅热注入器路径会用到,作用是把最终的 `OpenZenLoader.exe` 从 ~32 MB 压到 ~10 MB。在 `PATH` 上检测到 `upx` 时 `./gradlew upxCompress` 会自动跑 `--best --lzma`;找不到就只打一条 warning 直接跳过,不影响功能。安装方式:
|
||||
|
||||
```powershell
|
||||
choco install upx -y
|
||||
```
|
||||
|
||||
或者从 <https://upx.github.io/> 下载 ZIP 并把 `upx.exe` 加进 `PATH`。
|
||||
|
||||
首次执行会从 ForgeMaven 下载 1.20.1 + Forge 47.4.20 的 mappings 和依赖,耗时几分钟到十几分钟,取决于网络。
|
||||
|
||||
### 1. 构建为 Java Agent jar
|
||||
|
||||
零额外依赖。
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat jar
|
||||
```
|
||||
|
||||
产物:`build/libs/hey-1.0.jar`。在 Forge 启动器的 JVM 启动参数里加上:
|
||||
|
||||
```
|
||||
-javaagent:"完整\路径\到\hey-1.0.jar" -Djdk.attach.allowAttachSelf=true
|
||||
```
|
||||
|
||||
`PatchAgent.premain` 会在 Minecraft 类加载之前装载所有 ASM 补丁;`-Djdk.attach.allowAttachSelf=true` 是让 `installPatchesAndRetransform` 在需要时能兜底走 Attach API。
|
||||
|
||||
### 2. 构建为热注入器 (单文件 EXE)
|
||||
|
||||
产出一个独立的 `OpenZenLoader.exe`,DLL 已经作为资源段嵌入 EXE 内部。用户分发只需要这一个文件,运行后 GUI 列出当前所有 `javaw.exe` 进程(含 Minecraft 窗口标题),选中后点 Inject 即可。
|
||||
|
||||
#### 额外前置 — 必须项
|
||||
|
||||
1. **Visual Studio 2022**(Community 版即可,免费)。安装时勾选:
|
||||
- **"使用 C++ 的桌面开发"** 工作负载
|
||||
- 该工作负载的可选组件里勾上 **"适用于 Windows 的 C++ CMake 工具"**("C++ CMake tools for Windows")
|
||||
2. **`JAVA_HOME` 必须指向 JDK 17**(不只是 JRE)。CMake 需要它定位 `<JAVA_HOME>/include/jni.h` 和 `<JAVA_HOME>/include/win32/jvmti.h`。
|
||||
3. **CMake**:VS 2022 自带,Gradle 会自动检测——也可以独立安装 [CMake](https://cmake.org/download/) 并加入 PATH。Gradle 的检测顺序:
|
||||
1. `PATH` 上的 `cmake.exe`
|
||||
2. 通过 `vswhere.exe` 找 VS 2022 自带的 CMake
|
||||
3. 常见独立安装位置 (`%ProgramFiles%\CMake\bin\cmake.exe` 等)
|
||||
4. ~~vcpkg~~ **不再需要**。注入器 GUI 已改为纯 Win32 + GDI+(Windows SDK 自带,零第三方依赖),首次编译也从"30 分钟到 2 小时"降到几秒钟。`OpenZenLoader.exe` 依然是单文件——自带 OpenZen.dll,零运行时依赖。
|
||||
|
||||
#### 构建命令
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat dll
|
||||
```
|
||||
|
||||
产物:`build/dist/OpenZenLoader.exe`。如果已装 UPX 想顺便压缩,跑 `.\gradlew.bat upxCompress`。
|
||||
|
||||
#### 使用注入器
|
||||
|
||||
1. 用 HMCL / Forge 启动器正常启动 Minecraft 1.20.1 Forge(**不需要**任何特殊 JVM 参数)。
|
||||
2. 双击 `OpenZenLoader.exe`。
|
||||
3. GUI 自动列出系统里**所有 Minecraft 实例**,每秒自动刷新一次。
|
||||
4. 点击行最后的 Inject 按钮。
|
||||
|
||||
也可以**不开 GUI** 直接命令行注入(成功静默退出,失败弹窗提示):
|
||||
|
||||
```powershell
|
||||
.\OpenZenLoader.exe 34028 --nogui # 注入指定 PID(--nogui 可省略,给了 PID 就是无 UI 模式)
|
||||
.\OpenZenLoader.exe --nogui # 向所有检测到的 Minecraft 实例注入
|
||||
```
|
||||
|
||||
诊断日志:
|
||||
- Native 端:`%TEMP%\openzen.log`
|
||||
- Java 端:Minecraft 自己的 `logs/latest.log`(类名已被构建时混淆、logger 名是随机的,改用固定日志文案定位,如 `bootstrap.start`、`bridge.load`、`agent attached`、`Runtime mapping`)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 布吉岛反作弊绕过
|
||||
~~截止至目前(2026/05/23),布吉岛并未检测本项目,考虑其反作弊为黑名单类名机制。~~
|
||||
~~建议构建时修改类名。~~
|
||||
~~类名已黑名单,请在构建时修改类名。~~
|
||||
类名已黑名单。现在**每次构建都会自动随机化全部类名**(见上方[编译时类名混淆](#编译时类名混淆重要))——但**务必自己 Fork/clone 编译**,别直接用 GitHub Actions / Release 上的预编译版:那是固定的一套名字,会被拉黑。
|
||||
|
||||
## 致谢
|
||||
|
||||
- 原始混淆客户端:**Zen**。
|
||||
- 反混淆、符号还原与工程脚手架:**Claude** 在人工监督下完成。
|
||||
- [Java Deobfuscator](https://github.com/java-deobfuscator/deobfuscator)
|
||||
- 从古墓中挖出的 [Themida](https://www.oreans.com/Themida.php)
|
||||
- 惨遭魔改的 [Zelix](https://www.zelix.com/)
|
||||
- [Enigma MCP](https://github.com/Margele/Enigma-MCP)
|
||||
@@ -0,0 +1,672 @@
|
||||
import org.objectweb.asm.ClassReader
|
||||
import org.objectweb.asm.ClassWriter
|
||||
import org.objectweb.asm.ClassVisitor
|
||||
import org.objectweb.asm.Opcodes
|
||||
import org.objectweb.asm.commons.ClassRemapper
|
||||
import org.objectweb.asm.commons.Remapper
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
// ASM is used by the build-time class-name obfuscation step (ext.obfuscateJar).
|
||||
// It runs on the already-built jar, so it lives on the buildscript classpath
|
||||
// rather than the project compile/runtime classpath.
|
||||
//
|
||||
// NOTE: the buildscript-classpath ASM also backs Groovy's build-script type
|
||||
// resolution, so it must be able to read the class files of the JDK that runs
|
||||
// Gradle. ASM 9.6 supports up to Java 21 (major version 65) — run Gradle on the
|
||||
// project's JDK 17 (not a newer JDK like 25/major 69, which 9.6 cannot read).
|
||||
classpath 'org.ow2.asm:asm-commons:9.6'
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'maven-publish'
|
||||
id 'net.minecraftforge.gradle' version '[6.0,6.2)'
|
||||
}
|
||||
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
base {
|
||||
archivesName = mod_id
|
||||
}
|
||||
|
||||
// Mojang ships Java 17 to end users in 1.18+, so your mod should target Java 17.
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(17)
|
||||
|
||||
println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"
|
||||
minecraft {
|
||||
// The mappings can be changed at any time and must be in the following format.
|
||||
// Channel: Version:
|
||||
// official MCVersion Official field/method names from Mojang mapping files
|
||||
// parchment YYYY.MM.DD-MCVersion Open community-sourced parameter names and javadocs layered on top of official
|
||||
//
|
||||
// You must be aware of the Mojang license when using the 'official' or 'parchment' mappings.
|
||||
// See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
|
||||
//
|
||||
// Parchment is an unofficial project maintained by ParchmentMC, separate from MinecraftForge
|
||||
// Additional setup is needed to use their mappings: https://parchmentmc.org/docs/getting-started
|
||||
//
|
||||
// Use non-default mappings at your own risk. They may not always work.
|
||||
// Simply re-run your setup task after changing the mappings to update your workspace.
|
||||
mappings channel: mapping_channel, version: mapping_version
|
||||
|
||||
// When true, this property will have all Eclipse/IntelliJ IDEA run configurations run the "prepareX" task for the given run configuration before launching the game.
|
||||
// In most cases, it is not necessary to enable.
|
||||
// enableEclipsePrepareRuns = true
|
||||
// enableIdeaPrepareRuns = true
|
||||
|
||||
// This property allows configuring Gradle's ProcessResources task(s) to run on IDE output locations before launching the game.
|
||||
// It is REQUIRED to be set to true for this template to function.
|
||||
// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html
|
||||
copyIdeResources = true
|
||||
|
||||
// When true, this property will add the folder name of all declared run configurations to generated IDE run configurations.
|
||||
// The folder name can be set on a run configuration using the "folderName" property.
|
||||
// By default, the folder name of a run configuration is the name of the Gradle project containing it.
|
||||
// generateRunFolders = true
|
||||
|
||||
// This property enables access transformers for use in development.
|
||||
// They will be applied to the Minecraft artifact.
|
||||
// The access transformer file can be anywhere in the project.
|
||||
// However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge.
|
||||
// This default location is a best practice to automatically put the file in the right place in the final jar.
|
||||
// See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information.
|
||||
// accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg')
|
||||
|
||||
// Default run configurations.
|
||||
// These can be tweaked, removed, or duplicated as needed.
|
||||
runs {
|
||||
// applies to all the run configs below
|
||||
configureEach {
|
||||
workingDirectory project.file('run')
|
||||
|
||||
// Recommended logging data for a userdev environment
|
||||
// The markers can be added/remove as needed separated by commas.
|
||||
// "SCAN": For mods scan.
|
||||
// "REGISTRIES": For firing of registry events.
|
||||
// "REGISTRYDUMP": For getting the contents of all registries.
|
||||
property 'forge.logging.markers', 'REGISTRIES'
|
||||
|
||||
// Recommended logging level for the console
|
||||
// You can set various levels here.
|
||||
// Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels
|
||||
property 'forge.logging.console.level', 'debug'
|
||||
|
||||
mods {
|
||||
"${mod_id}" {
|
||||
source sourceSets.main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client {
|
||||
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
|
||||
property 'forge.enabledGameTestNamespaces', mod_id
|
||||
// Inject the built mod jar as a Java agent so PatchAgent.premain() runs before
|
||||
// Minecraft classes are loaded. Use runClient0 (defined below) to ensure the jar is
|
||||
// built first.
|
||||
jvmArgs.add "-javaagent:${tasks.jar.archiveFile.get().asFile.absolutePath}".toString()
|
||||
// Allow the agent to attach to itself if PatchAgent.installPatchesAndRetransform
|
||||
// needs to fall back to the Attach API.
|
||||
jvmArgs.add "-Djdk.attach.allowAttachSelf=true".toString()
|
||||
}
|
||||
|
||||
server {
|
||||
property 'forge.enabledGameTestNamespaces', mod_id
|
||||
args '--nogui'
|
||||
}
|
||||
|
||||
// This run config launches GameTestServer and runs all registered gametests, then exits.
|
||||
// By default, the server will crash when no gametests are provided.
|
||||
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||
gameTestServer {
|
||||
property 'forge.enabledGameTestNamespaces', mod_id
|
||||
}
|
||||
|
||||
data {
|
||||
// example of overriding the workingDirectory set in configureEach above
|
||||
workingDirectory project.file('run-data')
|
||||
|
||||
// Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources.
|
||||
args '--mod', mod_id, '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include resources generated by data generators.
|
||||
sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||
|
||||
repositories {
|
||||
// Put repositories for dependencies here
|
||||
// ForgeGradle automatically adds the Forge maven and Maven Central for you
|
||||
|
||||
// If you have mod jar dependencies in ./libs, you can declare them as a repository like so.
|
||||
// See https://docs.gradle.org/current/userguide/declaring_repositories.html#sub:flat_dir_resolver
|
||||
// flatDir {
|
||||
// dir 'libs'
|
||||
// }
|
||||
|
||||
maven { url = "https://pkgs.dev.azure.com/djtheredstoner/DevAuth/_packaging/public/maven/v1" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Specify the version of Minecraft to use.
|
||||
// Any artifact can be supplied so long as it has a "userdev" classifier artifact and is a compatible patcher artifact.
|
||||
// The "userdev" classifier will be requested and setup by ForgeGradle.
|
||||
// If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"],
|
||||
// then special handling is done to allow a setup of a vanilla dependency without the use of an external repository.
|
||||
minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}"
|
||||
|
||||
compileOnly 'org.projectlombok:lombok:1.18.34'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.34'
|
||||
|
||||
// ASM (already on Forge classpath at runtime via Mojang deps, but declare for IDE/compile)
|
||||
implementation 'org.ow2.asm:asm:9.6'
|
||||
implementation 'org.ow2.asm:asm-tree:9.6'
|
||||
implementation 'org.ow2.asm:asm-commons:9.6'
|
||||
implementation 'org.ow2.asm:asm-util:9.6'
|
||||
|
||||
// Dev Auth
|
||||
runtimeOnly("me.djtheredstoner:DevAuth-forge-latest:1.2.2")
|
||||
|
||||
// Example mod dependency with JEI - using fg.deobf() ensures the dependency is remapped to your development mappings
|
||||
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
||||
// compileOnly fg.deobf("mezz.jei:jei-${mc_version}-common-api:${jei_version}")
|
||||
// compileOnly fg.deobf("mezz.jei:jei-${mc_version}-forge-api:${jei_version}")
|
||||
// runtimeOnly fg.deobf("mezz.jei:jei-${mc_version}-forge:${jei_version}")
|
||||
|
||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||
// The group id is ignored when searching -- in this case, it is "blank"
|
||||
// implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}")
|
||||
|
||||
// For more info:
|
||||
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
||||
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||
}
|
||||
|
||||
// This block of code expands all declared replace properties in the specified resource targets.
|
||||
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
|
||||
// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments.
|
||||
// See https://docs.gradle.org/current/dsl/org.gradle.language.jvm.tasks.ProcessResources.html
|
||||
tasks.named('processResources', ProcessResources).configure {
|
||||
var replaceProperties = [
|
||||
minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range,
|
||||
forge_version: forge_version, forge_version_range: forge_version_range,
|
||||
loader_version_range: loader_version_range,
|
||||
mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version,
|
||||
mod_authors: mod_authors, mod_description: mod_description,
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
|
||||
filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) {
|
||||
expand replaceProperties + [project: project]
|
||||
}
|
||||
}
|
||||
|
||||
// Example for how to get properties into the manifest for reading at runtime.
|
||||
tasks.named('jar', Jar).configure {
|
||||
manifest {
|
||||
attributes([
|
||||
'Specification-Title' : mod_id,
|
||||
'Specification-Vendor' : mod_authors,
|
||||
'Specification-Version' : '1', // We are version 1 of ourselves
|
||||
'Implementation-Title' : project.name,
|
||||
'Implementation-Version' : project.jar.archiveVersion,
|
||||
'Implementation-Vendor' : mod_authors,
|
||||
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
|
||||
// Java-agent entry points so the jar can be passed via -javaagent:
|
||||
'Premain-Class' : 'asm.patchify.loader.PatchAgent',
|
||||
'Agent-Class' : 'asm.patchify.loader.PatchAgent',
|
||||
'Can-Retransform-Classes' : 'true',
|
||||
'Can-Redefine-Classes' : 'true'
|
||||
])
|
||||
}
|
||||
|
||||
// This is the preferred method to reobfuscate your jar file
|
||||
finalizedBy 'reobfJar'
|
||||
}
|
||||
|
||||
// ===== Build-time class-name obfuscation =====
|
||||
//
|
||||
// Renames EVERY OpenZen class (shit.zen.** and asm.patchify.**) to an opaque
|
||||
// generated name, so no original class name survives in the distributed jar.
|
||||
// It runs on the reobf'd jar and only touches our two package trees —
|
||||
// net.minecraft.** / net.minecraftforge.** references are left exactly as
|
||||
// ForgeGradle's mojmap->SRG reobf produced them, so reobf and runtime
|
||||
// remapping are unaffected.
|
||||
//
|
||||
// CLASS NAMES ONLY: method/field names are preserved, so the JNI method lookups
|
||||
// (GameLoaderBridge.load, DllBootstrap.start), reflection, GSON @SerializedName
|
||||
// wire keys and the manifest member contracts all keep working.
|
||||
//
|
||||
// NOTHING IS HARD-CODED. The three bootstrap classes referenced by string
|
||||
// OUTSIDE the bytecode the remapper rewrites are kept in lockstep by
|
||||
// propagating their freshly generated names:
|
||||
// asm.patchify.loader.PatchAgent -> jar manifest Premain/Agent-Class (rewritten below)
|
||||
// shit.zen.dll.DllBootstrap -> Class.forName(...) string literal (rewritten via Remapper.mapValue)
|
||||
// shit.zen.dll.GameLoaderBridge -> native DLL loader, via a generated C++ header
|
||||
// (native/dll/src/generated_names.h) emitted below.
|
||||
ext.obfuscateJar = { File jarFile, File mappingOut ->
|
||||
def owned = { String internal -> internal.startsWith('shit/zen/') || internal.startsWith('asm/patchify/') }
|
||||
|
||||
// Pass 1: enumerate the classes we own (every shit.zen.* / asm.patchify.* class).
|
||||
def ownedNames = []
|
||||
new java.util.zip.ZipFile(jarFile).withCloseable { zf ->
|
||||
for (entry in Collections.list(zf.entries())) {
|
||||
if (!entry.directory && entry.name.endsWith('.class')) {
|
||||
def internal = entry.name.substring(0, entry.name.length() - 6)
|
||||
if (owned(internal)) ownedNames << internal
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ownedNames.isEmpty()) {
|
||||
logger.lifecycle("obfuscateJar: no original class names in ${jarFile.name} (already obfuscated) — skipping")
|
||||
return
|
||||
}
|
||||
// Pass 2: assign each owned class a FRESH RANDOM 16-char name, so the obfuscated
|
||||
// names differ on every build and encode nothing. Uniqueness is enforced. The
|
||||
// names are unpredictable, so build/rename-mapping.txt (written below) is the only
|
||||
// way to de-obfuscate a stack trace — each build's mapping is different; keep it.
|
||||
//
|
||||
// ALL classes go into ONE shared (also random, 16-char) package. They must share a
|
||||
// single package so that package-private (default-access) members originally used
|
||||
// between same-package classes still resolve — flattening into one package only
|
||||
// widens access, never breaks it. Per-class packages would turn those into illegal
|
||||
// cross-package accesses (IllegalAccessError) unless every member were made public.
|
||||
def typeMap = [:]
|
||||
def usedNames = new HashSet()
|
||||
def secureRandom = new java.security.SecureRandom()
|
||||
def leadAlphabet = (('a'..'z') + ('A'..'Z')).join('') // first char: a letter
|
||||
def nameAlphabet = (('a'..'z') + ('A'..'Z') + ('0'..'9')).join('') // rest: alphanumeric
|
||||
def randomName = {
|
||||
def sb = new StringBuilder(16)
|
||||
sb.append(leadAlphabet.charAt(secureRandom.nextInt(leadAlphabet.length())))
|
||||
15.times { sb.append(nameAlphabet.charAt(secureRandom.nextInt(nameAlphabet.length()))) }
|
||||
sb.toString()
|
||||
}
|
||||
def obfPackage = randomName() // one random package for every class (see note above)
|
||||
ownedNames.each { internal ->
|
||||
def newName
|
||||
while (true) { newName = randomName(); if (usedNames.add(newName)) break }
|
||||
typeMap[internal] = obfPackage + '/' + newName
|
||||
}
|
||||
// String-constant remap table — catches class names embedded as String
|
||||
// literals (e.g. Class.forName("shit.zen.dll.DllBootstrap")). Both the
|
||||
// dotted (Class.forName) and slash (internal) spellings are covered.
|
||||
def stringMap = [:]
|
||||
typeMap.each { o, n ->
|
||||
stringMap[o.replace('/', '.')] = n.replace('/', '.')
|
||||
stringMap[o] = n
|
||||
}
|
||||
|
||||
def remapper = new Remapper() {
|
||||
String map(String internalName) {
|
||||
def n = typeMap[internalName]
|
||||
return n != null ? n : internalName
|
||||
}
|
||||
|
||||
Object mapValue(Object value) {
|
||||
if (value instanceof String) {
|
||||
def repl = stringMap[value]
|
||||
if (repl != null) return repl
|
||||
}
|
||||
return super.mapValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: rewrite into a temp jar, then atomically swap it in.
|
||||
def tmp = new File(jarFile.parentFile, jarFile.name + '.obf')
|
||||
tmp.delete()
|
||||
new java.util.zip.ZipFile(jarFile).withCloseable { zf ->
|
||||
new java.util.zip.ZipOutputStream(new FileOutputStream(tmp)).withCloseable { zos ->
|
||||
for (entry in Collections.list(zf.entries())) {
|
||||
if (entry.directory) continue
|
||||
def name = entry.name
|
||||
byte[] bytes = zf.getInputStream(entry).bytes
|
||||
if (name.endsWith('.class')) {
|
||||
def internal = name.substring(0, name.length() - 6)
|
||||
if (owned(internal)) {
|
||||
def cr = new ClassReader(bytes)
|
||||
def cw = new ClassWriter(0)
|
||||
// Drop the original SourceFile name ("ZenClient.java" etc.) but keep
|
||||
// line numbers so stack traces still carry positions.
|
||||
def stripSource = new ClassVisitor(Opcodes.ASM9, cw) {
|
||||
void visitSource(String source, String debug) { super.visitSource(null, null) }
|
||||
}
|
||||
cr.accept(new ClassRemapper(stripSource, remapper), 0)
|
||||
bytes = cw.toByteArray()
|
||||
name = typeMap[internal] + '.class'
|
||||
}
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.write(bytes); zos.closeEntry()
|
||||
} else if (name == 'META-INF/MANIFEST.MF') {
|
||||
def mf = new java.util.jar.Manifest(new ByteArrayInputStream(bytes))
|
||||
def attrs = mf.getMainAttributes()
|
||||
['Premain-Class', 'Agent-Class'].each { key ->
|
||||
def v = attrs.getValue(key)
|
||||
if (v != null) {
|
||||
def internal = v.replace('.', '/')
|
||||
if (typeMap.containsKey(internal)) attrs.putValue(key, typeMap[internal].replace('/', '.'))
|
||||
}
|
||||
}
|
||||
def bos = new ByteArrayOutputStream(); mf.write(bos)
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.write(bos.toByteArray()); zos.closeEntry()
|
||||
} else if (name.startsWith('META-INF/') &&
|
||||
(name.endsWith('.SF') || name.endsWith('.RSA') || name.endsWith('.DSA') || name.endsWith('.EC'))) {
|
||||
// Drop signature files — renaming class entries invalidates their digests.
|
||||
} else {
|
||||
// Resources verbatim: mapping.srg, webui/**, assets/**, fonts, mods.toml, pack.mcmeta.
|
||||
zos.putNextEntry(new java.util.zip.ZipEntry(name)); zos.write(bytes); zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!jarFile.delete()) throw new GradleException("obfuscateJar: could not delete ${jarFile}")
|
||||
if (!tmp.renameTo(jarFile)) throw new GradleException("obfuscateJar: could not move ${tmp} -> ${jarFile}")
|
||||
|
||||
mappingOut.parentFile.mkdirs()
|
||||
mappingOut.withWriter('UTF-8') { w ->
|
||||
typeMap.sort { it.key }.each { o, n -> w.writeLine("${o.replace('/', '.')} -> ${n.replace('/', '.')}") }
|
||||
}
|
||||
|
||||
// Propagate the GameLoaderBridge name to the native DLL loader. class_loader.cpp
|
||||
// #includes this generated header and loads the class by OZ_BRIDGE_FQCN, so the
|
||||
// native side always matches whatever opaque name the bridge received — no
|
||||
// hard-coded class name. Generated, never committed (see .gitignore).
|
||||
def bridgeFqcn = typeMap['shit/zen/dll/GameLoaderBridge'].replace('/', '.')
|
||||
def header = file('native/dll/src/generated_names.h')
|
||||
header.parentFile.mkdirs()
|
||||
header.text = """\
|
||||
// AUTO-GENERATED by build.gradle (ext.obfuscateJar). DO NOT EDIT, DO NOT COMMIT.
|
||||
// The build renames every OpenZen class to an opaque name; this captures the
|
||||
// generated FQCN of the DLL bootstrap bridge (originally shit.zen.dll.GameLoaderBridge)
|
||||
// so the native loader can request it by name. The bridge's load(String, ClassLoader)
|
||||
// method name is preserved by the rename, so main.cpp's GetStaticMethodID still works.
|
||||
#pragma once
|
||||
#define OZ_BRIDGE_FQCN "${bridgeFqcn}"
|
||||
"""
|
||||
|
||||
logger.lifecycle("obfuscateJar: renamed ${typeMap.size()} classes in ${jarFile.name}; " +
|
||||
"bridge=${bridgeFqcn}; mapping -> ${mappingOut}")
|
||||
}
|
||||
|
||||
tasks.register('obfuscateClasses') {
|
||||
group = 'openzen'
|
||||
description = 'Rename every OpenZen class to an opaque name in the built jar (class names only).'
|
||||
dependsOn 'reobfJar'
|
||||
doLast {
|
||||
obfuscateJar(tasks.jar.archiveFile.get().asFile, file("$buildDir/rename-mapping.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run after every reobf so `gradlew jar` / `build` / `dll` all emit obfuscated
|
||||
// names. reobfJar is created lazily by ForgeGradle, so wire via matching/configureEach.
|
||||
tasks.matching { it.name == 'reobfJar' }.configureEach { finalizedBy 'obfuscateClasses' }
|
||||
|
||||
// However if you are in a multi-project build, dev time needs unobfed jar files, so you can delay the obfuscation until publishing by doing:
|
||||
// tasks.named('publish').configure {
|
||||
// dependsOn 'reobfJar'
|
||||
// }
|
||||
|
||||
// IZMK-style entry point: build the jar first (so the -javaagent path exists) and then launch
|
||||
// the client. The client run config above already references tasks.jar via archiveFile.
|
||||
tasks.register('runClient0') {
|
||||
group = 'forgegradle runs'
|
||||
description = 'Build the mod jar (used as -javaagent) and start the client.'
|
||||
dependsOn tasks.jar
|
||||
finalizedBy tasks.runClient
|
||||
}
|
||||
|
||||
// Example configuration to allow publishing using the maven-publish plugin
|
||||
publishing {
|
||||
publications {
|
||||
register('mavenJava', MavenPublication) {
|
||||
artifact jar
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url "file://${project.projectDir}/mcmodsrepo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation
|
||||
}
|
||||
|
||||
// ===== Native build (DLL injection path) =====
|
||||
//
|
||||
// Produces:
|
||||
// build/dist/OpenZenLoader.exe - GUI injector (OpenZen.dll embedded)
|
||||
//
|
||||
// Requires:
|
||||
// - JAVA_HOME pointing at a JDK 17 install (for jni.h / jvmti.h)
|
||||
// - CMake either on PATH, bundled with Visual Studio 2019/2022
|
||||
// ("C++ CMake tools for Windows" workload), or installed standalone.
|
||||
// - MSVC build tools (Visual Studio 2019+).
|
||||
//
|
||||
// The loader GUI is pure Win32 + GDI+ — no Qt, no vcpkg, no third-party
|
||||
// downloads — so the native build needs nothing beyond the Windows SDK.
|
||||
//
|
||||
// Usage:
|
||||
// ./gradlew dll
|
||||
//
|
||||
def nativeDir = file('native')
|
||||
def nativeBuildDir = file("$nativeDir/build")
|
||||
|
||||
ext.findCmake = {
|
||||
// 1. cmake already on PATH?
|
||||
try {
|
||||
def p = ['cmd', '/c', 'where', 'cmake'].execute()
|
||||
def out = new StringBuilder()
|
||||
p.consumeProcessOutput(out, new StringBuilder())
|
||||
p.waitForOrKill(2000)
|
||||
if (p.exitValue() == 0) {
|
||||
def first = out.toString().readLines().find { it?.trim() }
|
||||
if (first) return first.trim()
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// 2. Visual Studio bundled cmake via vswhere.
|
||||
def pf86 = System.getenv('ProgramFiles(x86)') ?: 'C:/Program Files (x86)'
|
||||
def vswhere = "${pf86}/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
if (file(vswhere).exists()) {
|
||||
try {
|
||||
def out = new StringBuilder()
|
||||
def p = [vswhere, '-latest', '-products', '*',
|
||||
'-requires', 'Microsoft.VisualStudio.Component.VC.CMake.Project',
|
||||
'-property', 'installationPath'].execute()
|
||||
p.consumeProcessOutput(out, new StringBuilder())
|
||||
p.waitForOrKill(5000)
|
||||
def vsPath = out.toString().trim()
|
||||
if (vsPath) {
|
||||
def cmakeExe = "${vsPath}/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe"
|
||||
if (file(cmakeExe).exists()) return cmakeExe
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// 3. Standalone CMake installations.
|
||||
def pf = System.getenv('ProgramFiles') ?: 'C:/Program Files'
|
||||
def lad = System.getenv('LOCALAPPDATA') ?: ''
|
||||
def candidates = [
|
||||
"${pf}/CMake/bin/cmake.exe",
|
||||
"${pf86}/CMake/bin/cmake.exe",
|
||||
"${lad}/Programs/CMake/bin/cmake.exe",
|
||||
]
|
||||
for (path in candidates) {
|
||||
if (path && file(path).exists()) return path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
def cmakeMissingMessage =
|
||||
'CMake not found. Install one of:\n' +
|
||||
' - Standalone CMake: https://cmake.org/download/ (add to PATH)\n' +
|
||||
' - Visual Studio 2022 with "C++ CMake tools for Windows" workload\n' +
|
||||
'After installing, re-run ./gradlew dll'
|
||||
|
||||
ext.gitShortRevision = {
|
||||
// CI passes the resolved sha in OPENZEN_BUILD_REVISION so we don't have
|
||||
// to assume git is on PATH inside the runner image.
|
||||
def envRev = System.getenv('OPENZEN_BUILD_REVISION')
|
||||
if (envRev && !envRev.allWhitespace) return envRev.trim().take(7)
|
||||
try {
|
||||
def p = ['git', 'rev-parse', '--short=7', 'HEAD'].execute(null, project.rootDir)
|
||||
def out = new StringBuilder()
|
||||
p.consumeProcessOutput(out, new StringBuilder())
|
||||
p.waitForOrKill(2000)
|
||||
if (p.exitValue() == 0) {
|
||||
def v = out.toString().trim()
|
||||
if (v) return v
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return null
|
||||
}
|
||||
|
||||
tasks.register('stageNativeJar', Copy) {
|
||||
group = 'openzen'
|
||||
description = 'Stage the freshly built mod jar at native/zen.jar so the DLL ' +
|
||||
'resource compiler can embed it.'
|
||||
// obfuscateClasses (which dependsOn reobfJar, which dependsOn jar) rewrites
|
||||
// the jar in place, so depend on it to guarantee the DLL embeds the
|
||||
// obfuscated jar rather than racing the rename.
|
||||
dependsOn 'obfuscateClasses'
|
||||
from { tasks.jar.archiveFile }
|
||||
into nativeDir
|
||||
rename { 'zen.jar' }
|
||||
}
|
||||
|
||||
tasks.register('configureNative', Exec) {
|
||||
group = 'openzen'
|
||||
description = 'Run CMake configure on the native sub-projects (DLL + Loader).'
|
||||
dependsOn 'stageNativeJar'
|
||||
workingDir nativeDir
|
||||
commandLine 'cmake', '-S', '.', '-B', 'build', '-A', 'x64'
|
||||
doFirst {
|
||||
def cmake = findCmake()
|
||||
if (!cmake) throw new GradleException(cmakeMissingMessage)
|
||||
def rev = gitShortRevision()
|
||||
logger.lifecycle("Using CMake : ${cmake}")
|
||||
logger.lifecycle("Build rev : ${rev ?: '(unknown)'}")
|
||||
def args = [cmake, '-S', '.', '-B', 'build', '-A', 'x64']
|
||||
if (rev) args << "-DOPENZEN_BUILD_REVISION=${rev}"
|
||||
commandLine args
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('buildNative', Exec) {
|
||||
group = 'openzen'
|
||||
description = 'Compile OpenZen.dll and OpenZenLoader.exe with CMake.'
|
||||
dependsOn 'configureNative'
|
||||
workingDir nativeDir
|
||||
commandLine 'cmake', '--build', 'build', '--config', 'Release'
|
||||
doFirst {
|
||||
def cmake = findCmake()
|
||||
if (!cmake) throw new GradleException(cmakeMissingMessage)
|
||||
// --parallel forwards a job count to the generator (MSBuild gets /m,
|
||||
// Ninja gets -j). MSBuild defaults to a single process otherwise.
|
||||
def jobs = Runtime.runtime.availableProcessors()
|
||||
logger.lifecycle("Parallel jobs: ${jobs}")
|
||||
commandLine cmake, '--build', 'build', '--config', 'Release',
|
||||
'--parallel', jobs.toString()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('packageDist') {
|
||||
group = 'openzen'
|
||||
description = 'Collect the Loader EXE into build/dist for distribution. ' +
|
||||
'OpenZen.dll is embedded inside the EXE, so we only ship one file.'
|
||||
dependsOn 'buildNative'
|
||||
// Use an inline copy { } block inside doLast rather than the Copy task
|
||||
// type. Copy snapshots its source set at configuration time and reports
|
||||
// NO-SOURCE on a first clean build because the EXE that buildNative is
|
||||
// about to produce does not exist yet. doLast runs after buildNative,
|
||||
// by which point the file is definitely on disk.
|
||||
doLast {
|
||||
def src = file("$nativeBuildDir/loader/Release/OpenZenLoader.exe")
|
||||
if (!src.isFile()) {
|
||||
throw new GradleException(
|
||||
"Expected OpenZenLoader.exe at ${src} after buildNative; missing")
|
||||
}
|
||||
copy {
|
||||
from src
|
||||
into "$buildDir/dist"
|
||||
}
|
||||
logger.lifecycle("Packaged ${src.length()} bytes -> ${buildDir}/dist/${src.name}")
|
||||
}
|
||||
}
|
||||
|
||||
ext.findUpx = {
|
||||
try {
|
||||
def p = ['cmd', '/c', 'where', 'upx'].execute()
|
||||
def out = new StringBuilder()
|
||||
p.consumeProcessOutput(out, new StringBuilder())
|
||||
p.waitForOrKill(2000)
|
||||
if (p.exitValue() == 0) {
|
||||
def first = out.toString().readLines().find { it?.trim() }
|
||||
if (first) return first.trim()
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return null
|
||||
}
|
||||
|
||||
tasks.register('upxCompress') {
|
||||
group = 'openzen'
|
||||
description = 'Run UPX --best --lzma on the packaged OpenZenLoader.exe. ' +
|
||||
'No-op (warning only) when upx is not on PATH so local builds ' +
|
||||
'do not need it installed.'
|
||||
dependsOn 'packageDist'
|
||||
doLast {
|
||||
def exe = file("$buildDir/dist/OpenZenLoader.exe")
|
||||
if (!exe.isFile()) {
|
||||
throw new GradleException("packageDist did not produce ${exe}")
|
||||
}
|
||||
def upx = findUpx()
|
||||
if (!upx) {
|
||||
logger.warn("upx not on PATH; skipping compression. Install from " +
|
||||
"https://upx.github.io/ to enable.")
|
||||
return
|
||||
}
|
||||
def before = exe.length()
|
||||
exec {
|
||||
commandLine upx, '--best', '--lzma', exe.absolutePath
|
||||
}
|
||||
def after = exe.length()
|
||||
logger.lifecycle(String.format(
|
||||
"UPX: %,d -> %,d bytes (%.1f%% of original)",
|
||||
before, after, after * 100.0 / before))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('dll') {
|
||||
group = 'openzen'
|
||||
description = 'Build the injectable DLL and GUI Loader (final dist artifacts).'
|
||||
dependsOn 'packageDist'
|
||||
}
|
||||
|
||||
// Gradle's stock 'clean' only wipes the project build/ directory, so the
|
||||
// CMake out-of-source build dir and the staged jar would survive a clean
|
||||
// and silently feed stale bits into the next dll build. Wire a dedicated
|
||||
// Delete task into 'clean' so ./gradlew clean really wipes everything.
|
||||
tasks.register('cleanNative', Delete) {
|
||||
group = 'openzen'
|
||||
description = 'Remove native/build/ and the staged native/zen.jar.'
|
||||
delete nativeBuildDir
|
||||
delete file("$nativeDir/zen.jar")
|
||||
}
|
||||
|
||||
tasks.named('clean').configure {
|
||||
dependsOn 'cleanNative'
|
||||
}
|
||||
|
||||
// Release builds are cut by .github/workflows/build-loader.yml when the head commit
|
||||
// message contains [Release]. Every build emits fresh random class names — see
|
||||
// ext.obfuscateJar above and the "编译时类名混淆" section in the README.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
|
||||
# This is required to provide enough memory for the Minecraft decompilation process.
|
||||
org.gradle.jvmargs=-Xmx3G
|
||||
org.gradle.daemon=false
|
||||
|
||||
|
||||
## Environment Properties
|
||||
|
||||
# The Minecraft version must agree with the Forge version to get a valid artifact
|
||||
minecraft_version=1.20.1
|
||||
# The Minecraft version range can use any release version of Minecraft as bounds.
|
||||
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
|
||||
# as they do not follow standard versioning conventions.
|
||||
minecraft_version_range=[1.20.1,1.21)
|
||||
# The Forge version must agree with the Minecraft version to get a valid artifact
|
||||
forge_version=47.4.20
|
||||
# The Forge version range can use any version of Forge as bounds or match the loader version range
|
||||
forge_version_range=[47,)
|
||||
# The loader version range can only use the major version of Forge/FML as bounds
|
||||
loader_version_range=[47,)
|
||||
# The mapping channel to use for mappings.
|
||||
# The default set of supported mapping channels are ["official", "snapshot", "snapshot_nodoc", "stable", "stable_nodoc"].
|
||||
# Additional mapping channels can be registered through the "channelProviders" extension in a Gradle plugin.
|
||||
#
|
||||
# | Channel | Version | |
|
||||
# |-----------|----------------------|--------------------------------------------------------------------------------|
|
||||
# | official | MCVersion | Official field/method names from Mojang mapping files |
|
||||
# | parchment | YYYY.MM.DD-MCVersion | Open community-sourced parameter names and javadocs layered on top of official |
|
||||
#
|
||||
# You must be aware of the Mojang license when using the 'official' or 'parchment' mappings.
|
||||
# See more information here: https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
|
||||
#
|
||||
# Parchment is an unofficial project maintained by ParchmentMC, separate from Minecraft Forge.
|
||||
# Additional setup is needed to use their mappings, see https://parchmentmc.org/docs/getting-started
|
||||
mapping_channel=official
|
||||
# The mapping version to query from the mapping channel.
|
||||
# This must match the format required by the mapping channel.
|
||||
mapping_version=1.20.1
|
||||
|
||||
|
||||
## Mod Properties
|
||||
|
||||
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
|
||||
# Must match the String constant located in the main mod class annotated with @Mod.
|
||||
mod_id=hey
|
||||
# The human-readable display name for the mod.
|
||||
mod_name=OpenZen
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=All Rights Reserved
|
||||
# The mod version. See https://semver.org/
|
||||
mod_version=1.0
|
||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||
# This should match the base package used for the mod sources.
|
||||
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||
mod_group_id=io.github.openzen
|
||||
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
|
||||
mod_authors=Shirona1337
|
||||
# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list.
|
||||
mod_description=Open sourced zen client
|
||||
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.8-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
之后 宽度: | 高度: | 大小: 67 KiB |
|
之后 宽度: | 高度: | 大小: 60 KiB |
|
之后 宽度: | 高度: | 大小: 82 KiB |
|
之后 宽度: | 高度: | 大小: 132 KiB |
|
之后 宽度: | 高度: | 大小: 83 KiB |
|
之后 宽度: | 高度: | 大小: 64 KiB |
|
之后 宽度: | 高度: | 大小: 64 KiB |
|
之后 宽度: | 高度: | 大小: 51 KiB |
|
之后 宽度: | 高度: | 大小: 39 KiB |
|
之后 宽度: | 高度: | 大小: 33 KiB |
|
之后 宽度: | 高度: | 大小: 51 KiB |
|
之后 宽度: | 高度: | 大小: 44 KiB |
|
之后 宽度: | 高度: | 大小: 42 KiB |
|
之后 宽度: | 高度: | 大小: 53 KiB |
|
之后 宽度: | 高度: | 大小: 34 KiB |
|
之后 宽度: | 高度: | 大小: 49 KiB |
|
之后 宽度: | 高度: | 大小: 69 KiB |
|
之后 宽度: | 高度: | 大小: 1.4 MiB |
|
之后 宽度: | 高度: | 大小: 8.2 KiB |
|
之后 宽度: | 高度: | 大小: 24 KiB |
|
之后 宽度: | 高度: | 大小: 127 KiB |
|
之后 宽度: | 高度: | 大小: 24 KiB |
|
之后 宽度: | 高度: | 大小: 13 KiB |
|
之后 宽度: | 高度: | 大小: 41 KiB |
|
之后 宽度: | 高度: | 大小: 50 KiB |
|
之后 宽度: | 高度: | 大小: 45 KiB |
|
之后 宽度: | 高度: | 大小: 46 KiB |
|
之后 宽度: | 高度: | 大小: 26 KiB |
|
之后 宽度: | 高度: | 大小: 358 KiB |
|
之后 宽度: | 高度: | 大小: 33 KiB |
@@ -0,0 +1,128 @@
|
||||
# Launch Minecraft 1.20.1 (Forge 47.4.23) with OpenZen agent injected via -javaagent
|
||||
param(
|
||||
[string]$Username = "Player"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$mcRoot = "C:\Users\Administrator\Desktop\nx\mc\.minecraft"
|
||||
$versionId = "1.20.1-Forge_47.4.23"
|
||||
$versionDir = Join-Path $mcRoot "versions\$versionId"
|
||||
$versionJson = Join-Path $versionDir "$versionId.json"
|
||||
$versionJar = Join-Path $versionDir "$versionId.jar"
|
||||
$nativesDir = Join-Path $versionDir "$versionId-natives"
|
||||
$libsDir = Join-Path $mcRoot "libraries"
|
||||
$assetsDir = Join-Path $mcRoot "assets"
|
||||
$agentJar = "C:\Users\Administrator\Desktop\nx\OpenZen\build\libs\hey-1.0.jar"
|
||||
$javaExe = "C:\Program Files\Common Files\Oracle\Java\javapath\javaw.exe"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $agentJar)) { throw "Agent jar not found: $agentJar (run gradlew build first)" }
|
||||
|
||||
$json = Get-Content -LiteralPath $versionJson -Raw | ConvertFrom-Json
|
||||
|
||||
# ---- classpath: windows-filtered libraries + client jar ----
|
||||
$libPaths = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($lib in $json.libraries) {
|
||||
if (-not ($lib.downloads -and $lib.downloads.artifact)) { continue }
|
||||
$allowed = $true
|
||||
if ($lib.rules) {
|
||||
$allowed = $false
|
||||
foreach ($r in $lib.rules) {
|
||||
if ($r.action -eq "allow" -and (-not $r.os -or $r.os.name -eq "windows")) { $allowed = $true; break }
|
||||
}
|
||||
}
|
||||
if ($allowed) {
|
||||
$p = Join-Path $libsDir $lib.downloads.artifact.path
|
||||
if (-not (Test-Path -LiteralPath $p)) { throw "Missing library: $p" }
|
||||
$libPaths.Add($p)
|
||||
}
|
||||
}
|
||||
$libPaths.Add($versionJar)
|
||||
$cp = $libPaths -join ";"
|
||||
|
||||
# ---- module path: same jars as json "-p" argument ----
|
||||
$moduleJarNames = @(
|
||||
"cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar",
|
||||
"cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"org/ow2/asm/asm-commons/9.9.1/asm-commons-9.9.1.jar",
|
||||
"org/ow2/asm/asm-util/9.9.1/asm-util-9.9.1.jar",
|
||||
"org/ow2/asm/asm-analysis/9.9.1/asm-analysis-9.9.1.jar",
|
||||
"org/ow2/asm/asm-tree/9.9.1/asm-tree-9.9.1.jar",
|
||||
"org/ow2/asm/asm/9.9.1/asm-9.9.1.jar",
|
||||
"net/minecraftforge/JarJarFileSystems/0.3.19/JarJarFileSystems-0.3.19.jar"
|
||||
)
|
||||
$mp = ($moduleJarNames | ForEach-Object { Join-Path $libsDir $_ }) -join ";"
|
||||
|
||||
# ---- offline auth (Mojang-style offline uuid) ----
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$hash = $md5.ComputeHash([Text.Encoding]::UTF8.GetBytes("OfflinePlayer:$Username"))
|
||||
$hash[6] = ($hash[6] -band 0x0F) -bor 0x30
|
||||
$hash[8] = ($hash[8] -band 0x3F) -bor 0x80
|
||||
$uuid = [Guid]$hash
|
||||
$uuidStr = $uuid.ToString().Replace("-", "")
|
||||
$accessToken = [Guid]::NewGuid().ToString().Replace("-", "")
|
||||
|
||||
# ---- java argument file (JDK9+ @argfile, avoids cmd length limits) ----
|
||||
function Add-Arg([System.Collections.Generic.List[string]]$list, [string]$arg) {
|
||||
if ($arg -match '\s') { $arg = '"' + $arg + '"' }
|
||||
$list.Add($arg)
|
||||
}
|
||||
|
||||
$args = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Add-Arg $args "-Xmx4G"
|
||||
Add-Arg $args "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"
|
||||
Add-Arg $args "-Xss1M"
|
||||
Add-Arg $args "-Djava.library.path=$nativesDir"
|
||||
Add-Arg $args "-Djna.tmpdir=$nativesDir"
|
||||
Add-Arg $args "-Dorg.lwjgl.system.SharedLibraryExtractPath=$nativesDir"
|
||||
Add-Arg $args "-Dio.netty.native.workdir=$nativesDir"
|
||||
Add-Arg $args "-Dminecraft.launcher.brand=PCL2"
|
||||
Add-Arg $args "-Dminecraft.launcher.version=2.9.3"
|
||||
Add-Arg $args "-Djava.net.preferIPv6Addresses=system"
|
||||
Add-Arg $args "-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-,$versionId.jar"
|
||||
Add-Arg $args "-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar"
|
||||
Add-Arg $args "-DlibraryDirectory=$libsDir"
|
||||
Add-Arg $args "-p"
|
||||
Add-Arg $args $mp
|
||||
Add-Arg $args "--add-modules"
|
||||
Add-Arg $args "ALL-MODULE-PATH"
|
||||
Add-Arg $args "--add-opens"
|
||||
Add-Arg $args "java.base/java.util.jar=cpw.mods.securejarhandler"
|
||||
Add-Arg $args "--add-opens"
|
||||
Add-Arg $args "java.base/java.lang.invoke=cpw.mods.securejarhandler"
|
||||
Add-Arg $args "--add-exports"
|
||||
Add-Arg $args "java.base/sun.security.util=cpw.mods.securejarhandler"
|
||||
Add-Arg $args "--add-exports"
|
||||
Add-Arg $args "jdk.naming.dns/com.sun.jndi.dns=java.naming"
|
||||
Add-Arg $args "-javaagent:$agentJar"
|
||||
Add-Arg $args "-cp"
|
||||
Add-Arg $args $cp
|
||||
Add-Arg $args $json.mainClass
|
||||
|
||||
Add-Arg $args "--launchTarget"; Add-Arg $args "forgeclient"
|
||||
Add-Arg $args "--fml.forgeVersion"; Add-Arg $args "47.4.23"
|
||||
Add-Arg $args "--fml.mcVersion"; Add-Arg $args "1.20.1"
|
||||
Add-Arg $args "--fml.forgeGroup"; Add-Arg $args "net.minecraftforge"
|
||||
Add-Arg $args "--fml.mcpVersion"; Add-Arg $args "20230612.114412"
|
||||
Add-Arg $args "--username"; Add-Arg $args $Username
|
||||
Add-Arg $args "--version"; Add-Arg $args $versionId
|
||||
Add-Arg $args "--gameDir"; Add-Arg $args $versionDir
|
||||
Add-Arg $args "--assetsDir"; Add-Arg $args $assetsDir
|
||||
Add-Arg $args "--assetIndex"; Add-Arg $args "5"
|
||||
Add-Arg $args "--uuid"; Add-Arg $args $uuidStr
|
||||
Add-Arg $args "--accessToken"; Add-Arg $args $accessToken
|
||||
Add-Arg $args "--userType"; Add-Arg $args "msa"
|
||||
Add-Arg $args "--versionType"; Add-Arg $args "OpenZen"
|
||||
|
||||
$argFile = Join-Path $env:TEMP "opencode\mc-launch-args.txt"
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $argFile) | Out-Null
|
||||
Set-Content -LiteralPath $argFile -Value $args -Encoding ASCII
|
||||
|
||||
Write-Output "Arg file: $argFile"
|
||||
Write-Output "Agent : $agentJar"
|
||||
Write-Output "Username: $Username (offline uuid $uuidStr)"
|
||||
|
||||
# ---- launch ----
|
||||
$proc = Start-Process -FilePath $javaExe -ArgumentList "@`"$argFile`"" -WorkingDirectory $versionDir -PassThru
|
||||
Write-Output ("Game process started, PID: " + $proc.Id)
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
maven {
|
||||
name = 'MinecraftForge'
|
||||
url = 'https://maven.minecraftforge.net/'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0'
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Accessor {
|
||||
Class<?> value();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE_USE)
|
||||
public @interface At {
|
||||
Type value() default Type.HEAD;
|
||||
|
||||
String method() default "";
|
||||
|
||||
String remapped() default "";
|
||||
|
||||
String desc() default "";
|
||||
|
||||
enum Type {
|
||||
BEFORE_INVOKE,
|
||||
AFTER_INVOKE,
|
||||
HEAD,
|
||||
TAIL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface FieldAccessor {
|
||||
String value();
|
||||
|
||||
boolean getter() default true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface Final {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface Inject {
|
||||
String method();
|
||||
|
||||
String desc();
|
||||
|
||||
At at() default @At(At.Type.HEAD);
|
||||
|
||||
Slice slice() default @Slice(start = @At(At.Type.HEAD), end = @At(At.Type.TAIL));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.PARAMETER)
|
||||
public @interface Local {
|
||||
int value();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface MethodAccessor {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface ModifyLocals {
|
||||
String method();
|
||||
|
||||
String desc();
|
||||
|
||||
int[] indexes();
|
||||
|
||||
Class<?>[] types();
|
||||
|
||||
At at() default @At(At.Type.HEAD);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface Overwrite {
|
||||
String method();
|
||||
|
||||
String desc();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Patch {
|
||||
Class<?> value() default void.class;
|
||||
|
||||
/**
|
||||
* Alternative to {@link #value()} for targeting classes that are not available at compile time.
|
||||
* When non-empty, takes precedence over {@code value()}. This is useful for targeting classes from
|
||||
* optional mods (e.g. Embeddium/Sodium).
|
||||
* <p>Use the fully qualified JVM class name (e.g. {@code "me.jellysquid.mods.sodium.client.render.chunk.compile.pipeline.BlockOcclusionCache"}).</p>
|
||||
*/
|
||||
String className() default "";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE_USE)
|
||||
public @interface Slice {
|
||||
At start() default @At(At.Type.HEAD);
|
||||
|
||||
At end() default @At(At.Type.TAIL);
|
||||
|
||||
int startIndex() default -1;
|
||||
|
||||
int endIndex() default -1;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface Transform {
|
||||
String method();
|
||||
|
||||
String desc();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package asm.patchify.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface WrapInvoke {
|
||||
String method();
|
||||
|
||||
String desc();
|
||||
|
||||
String target();
|
||||
|
||||
String targetDesc();
|
||||
|
||||
Slice slice() default @Slice;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package asm.patchify.loader;
|
||||
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.TypeInsnNode;
|
||||
|
||||
/**
|
||||
* Boxing / unboxing helpers used by the patchify-style transformer. Ported from
|
||||
* <a href="https://github.com/xiaojiang233/izmk-reborn">izmk-reborn</a>'s {@code ASMUtil}.
|
||||
*/
|
||||
public final class ASMHelpers {
|
||||
private ASMHelpers() {
|
||||
}
|
||||
|
||||
/** Pops an {@code Object} off the stack and replaces it with the unboxed primitive of {@code type}. */
|
||||
public static InsnList unboxFromObject(Type type) {
|
||||
InsnList list = new InsnList();
|
||||
switch (type.getSort()) {
|
||||
case Type.INT -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Integer"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Integer", "intValue", "()I", false));
|
||||
}
|
||||
case Type.BOOLEAN -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Boolean"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Boolean", "booleanValue", "()Z", false));
|
||||
}
|
||||
case Type.CHAR -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Character"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Character", "charValue", "()C", false));
|
||||
}
|
||||
case Type.BYTE -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Byte"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Byte", "byteValue", "()B", false));
|
||||
}
|
||||
case Type.SHORT -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Short"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Short", "shortValue", "()S", false));
|
||||
}
|
||||
case Type.LONG -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Long"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Long", "longValue", "()J", false));
|
||||
}
|
||||
case Type.FLOAT -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Float"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Float", "floatValue", "()F", false));
|
||||
}
|
||||
case Type.DOUBLE -> {
|
||||
list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Double"));
|
||||
list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/lang/Double", "doubleValue", "()D", false));
|
||||
}
|
||||
default -> list.add(new TypeInsnNode(Opcodes.CHECKCAST, type.getInternalName()));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Wraps the value currently on the stack in its boxed form (no-op for reference types). */
|
||||
public static InsnList boxToObject(Type type) {
|
||||
InsnList list = new InsnList();
|
||||
switch (type.getSort()) {
|
||||
case Type.INT -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false));
|
||||
case Type.BOOLEAN -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Boolean", "valueOf", "(Z)Ljava/lang/Boolean;", false));
|
||||
case Type.CHAR -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Character", "valueOf", "(C)Ljava/lang/Character;", false));
|
||||
case Type.BYTE -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Byte", "valueOf", "(B)Ljava/lang/Byte;", false));
|
||||
case Type.SHORT -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Short", "valueOf", "(S)Ljava/lang/Short;", false));
|
||||
case Type.LONG -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false));
|
||||
case Type.FLOAT -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Float", "valueOf", "(F)Ljava/lang/Float;", false));
|
||||
case Type.DOUBLE -> list.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Double", "valueOf", "(D)Ljava/lang/Double;", false));
|
||||
default -> list.add(new TypeInsnNode(Opcodes.CHECKCAST, "java/lang/Object"));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Splits {@code "owner/of/Class/methodName"} into {@code ("owner/of/Class", "methodName")}. */
|
||||
public static String[] splitOwnerName(String ownerSlashName) {
|
||||
int slash = ownerSlashName.lastIndexOf('/');
|
||||
if (slash < 0) {
|
||||
return new String[] {"", ownerSlashName};
|
||||
}
|
||||
return new String[] {ownerSlashName.substring(0, slash), ownerSlashName.substring(slash + 1)};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package asm.patchify.loader;
|
||||
|
||||
import java.lang.instrument.Instrumentation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Java agent entry point — loaded by the system class loader when the JVM is started with
|
||||
* {@code -javaagent:<this jar>}.
|
||||
*
|
||||
* <p>In a ForgeGradle dev environment the mod jar and the agent jar are the same file but get
|
||||
* loaded by different class loaders (system vs the Forge module layer). That means the static
|
||||
* fields on this class are NOT shared between agent-side and mod-side copies of
|
||||
* {@code PatchAgent}. To bridge the gap we stash {@link Instrumentation} in
|
||||
* {@link System#getProperties()} under {@link #INSTRUMENTATION_KEY} so the mod can retrieve it
|
||||
* regardless of which class loader it lives in.</p>
|
||||
*/
|
||||
public final class PatchAgent {
|
||||
public static final String INSTRUMENTATION_KEY = "oz.instrumentation";
|
||||
private static final Logger LOGGER = LogManager.getLogger(PatchAgent.class);
|
||||
|
||||
private static volatile boolean transformerInstalled = false;
|
||||
|
||||
private PatchAgent() {
|
||||
}
|
||||
|
||||
public static void premain(String args, Instrumentation inst) {
|
||||
install(inst);
|
||||
}
|
||||
|
||||
public static void agentmain(String args, Instrumentation inst) {
|
||||
install(inst);
|
||||
}
|
||||
|
||||
public static synchronized void install(Instrumentation inst) {
|
||||
Object existing = System.getProperties().get(INSTRUMENTATION_KEY);
|
||||
if (existing == inst) {
|
||||
return;
|
||||
}
|
||||
System.getProperties().put(INSTRUMENTATION_KEY, inst);
|
||||
LOGGER.info("agent attached, retransform supported = {}", inst.isRetransformClassesSupported());
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the {@link Instrumentation} stashed by {@link #premain}. Works across class loaders.
|
||||
*/
|
||||
public static Instrumentation getInstrumentation() {
|
||||
Object instObj = System.getProperties().get(INSTRUMENTATION_KEY);
|
||||
return instObj instanceof Instrumentation ? (Instrumentation) instObj : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a transformer for the currently registered patches and retransform any patch target
|
||||
* that is already loaded. Called from mod code once {@link PatchRegistry} is populated.
|
||||
*/
|
||||
public static synchronized void installPatchesAndRetransform() {
|
||||
if (transformerInstalled) {
|
||||
LOGGER.info("Patches already installed; skipping duplicate retransform request");
|
||||
return;
|
||||
}
|
||||
Instrumentation inst = getInstrumentation();
|
||||
if (inst == null) {
|
||||
LOGGER.warn("agent not attached; cannot install patches");
|
||||
return;
|
||||
}
|
||||
PatchClassFileTransformer transformer = new PatchClassFileTransformer();
|
||||
inst.addTransformer(transformer, true);
|
||||
transformerInstalled = true;
|
||||
List<Class<?>> retransform = new ArrayList<>();
|
||||
for (Class<?> patch : PatchRegistry.getPatches()) {
|
||||
asm.patchify.annotation.Patch ann = patch.getAnnotation(asm.patchify.annotation.Patch.class);
|
||||
if (ann == null) continue;
|
||||
if (!ann.className().isEmpty()) {
|
||||
// className-based patches target optional mod classes.
|
||||
// Check if the class is already loaded via Instrumentation.
|
||||
boolean found = false;
|
||||
for (Class<?> loaded : inst.getAllLoadedClasses()) {
|
||||
if (loaded.getName().equals(ann.className())) {
|
||||
LOGGER.debug("Found already-loaded target {} for className-based patch {}",
|
||||
ann.className(), patch.getName());
|
||||
if (inst.isModifiableClass(loaded)) {
|
||||
retransform.add(loaded);
|
||||
} else {
|
||||
LOGGER.warn("Cannot retransform unmodifiable target {}", ann.className());
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
LOGGER.debug("Target {} not yet loaded — transformer will catch it at class-load time",
|
||||
ann.className());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Class<?> target;
|
||||
try {
|
||||
target = ann.value();
|
||||
} catch (Throwable t) {
|
||||
LOGGER.warn("Patch target unresolved for {}: {}", patch.getName(), t.toString());
|
||||
continue;
|
||||
}
|
||||
if (inst.isModifiableClass(target)) {
|
||||
retransform.add(target);
|
||||
} else {
|
||||
LOGGER.warn("Cannot retransform unmodifiable target {}", target.getName());
|
||||
}
|
||||
}
|
||||
if (retransform.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Retransform one class at a time so we can pinpoint which patch produces invalid
|
||||
// bytecode if the JVM throws VerifyError / LinkageError.
|
||||
int success = 0;
|
||||
for (Class<?> target : retransform) {
|
||||
try {
|
||||
inst.retransformClasses(target);
|
||||
success++;
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("Retransform failed for {}: {}", target.getName(), t.toString());
|
||||
}
|
||||
}
|
||||
LOGGER.info("Retransformed {} / {} patch target(s)", success, retransform.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package asm.patchify.loader;
|
||||
|
||||
import asm.patchify.annotation.Patch;
|
||||
import java.io.IOException;
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.lang.instrument.IllegalClassFormatException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.ProtectionDomain;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
|
||||
/**
|
||||
* Java-agent {@link ClassFileTransformer} that applies registered patches at class load.
|
||||
*
|
||||
* <p>Indexes the patches by the JVM-internal name of their target class and rewrites class
|
||||
* bytes via ASM. Classes that have no patch are returned untouched (null).</p>
|
||||
*
|
||||
* <p>If the system property {@code oz.dumpDir} is set, every successfully
|
||||
* transformed class is written under that directory as {@code <internalName>.class} for
|
||||
* inspection (use {@code javap -v} or open in Recaf).</p>
|
||||
*/
|
||||
public final class PatchClassFileTransformer implements ClassFileTransformer {
|
||||
private static final Logger LOGGER = LogManager.getLogger(PatchClassFileTransformer.class);
|
||||
private static final String DUMP_DIR_PROPERTY = "oz.dumpDir";
|
||||
|
||||
private final Map<String, List<Class<?>>> patchesByTarget = new HashMap<>();
|
||||
|
||||
public PatchClassFileTransformer() {
|
||||
rebuildIndex();
|
||||
}
|
||||
|
||||
public void rebuildIndex() {
|
||||
patchesByTarget.clear();
|
||||
for (Class<?> patchClass : PatchRegistry.getPatches()) {
|
||||
Patch patch = patchClass.getAnnotation(Patch.class);
|
||||
if (patch == null) continue;
|
||||
String internalName;
|
||||
if (!patch.className().isEmpty()) {
|
||||
internalName = patch.className().replace('.', '/');
|
||||
} else {
|
||||
internalName = patch.value().getName().replace('.', '/');
|
||||
}
|
||||
patchesByTarget.computeIfAbsent(internalName, k -> new ArrayList<>()).add(patchClass);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
|
||||
if (className == null) {
|
||||
return null;
|
||||
}
|
||||
List<Class<?>> patches = patchesByTarget.get(className);
|
||||
if (patches == null || patches.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ClassReader reader = new ClassReader(classfileBuffer);
|
||||
ClassNode classNode = new ClassNode();
|
||||
reader.accept(classNode, 0);
|
||||
for (Class<?> patch : patches) {
|
||||
try {
|
||||
LOGGER.debug("Applying patch {} -> {}", patch.getName(), className);
|
||||
PatchTransformer.apply(patch, classNode);
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("Failed to apply patch {} -> {}", patch.getName(), className, t);
|
||||
}
|
||||
}
|
||||
// Override getClassLoader so COMPUTE_FRAMES resolves Minecraft / mod classes via the
|
||||
// class loader that actually owns the target class (Forge's TransformingClassLoader),
|
||||
// not the one that loaded our PatchTransformer.
|
||||
ClassLoader frameLoader = loader != null ? loader : Thread.currentThread().getContextClassLoader();
|
||||
ClassWriter writer = new FrameAwareClassWriter(reader, ClassWriter.COMPUTE_FRAMES, frameLoader);
|
||||
classNode.accept(writer);
|
||||
byte[] transformed = writer.toByteArray();
|
||||
dumpIfRequested(className, transformed);
|
||||
return transformed;
|
||||
} catch (Throwable t) {
|
||||
LOGGER.error("Failed to transform {}", className, t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpIfRequested(String internalName, byte[] bytes) {
|
||||
String dumpDir = System.getProperty(DUMP_DIR_PROPERTY);
|
||||
if (dumpDir == null) return;
|
||||
try {
|
||||
Path target = Path.of(dumpDir).resolve(internalName + ".class");
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.write(target, bytes);
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("Failed to dump transformed class {}", internalName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FrameAwareClassWriter extends ClassWriter {
|
||||
private final ClassLoader loader;
|
||||
|
||||
FrameAwareClassWriter(ClassReader reader, int flags, ClassLoader loader) {
|
||||
super(reader, flags);
|
||||
this.loader = loader;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ClassLoader getClassLoader() {
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package asm.patchify.loader;
|
||||
|
||||
import asm.patchify.annotation.Patch;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Runtime registry for {@link Patch}-annotated classes.
|
||||
*
|
||||
* <p>The original obfuscated client relied on a custom class-load transformer to wire up
|
||||
* {@code @Inject} / {@code @Overwrite} / {@code @WrapInvoke} handlers. The transformer is
|
||||
* provided by the loader and is not part of this restored source; this registry exposes the
|
||||
* patch list so a coremod / launch plugin can drive the transformation, and provides a
|
||||
* lightweight no-op fallback when no transformer is installed.</p>
|
||||
*/
|
||||
public final class PatchRegistry {
|
||||
private static final Logger LOGGER = LogManager.getLogger(PatchRegistry.class);
|
||||
private static final List<Class<?>> PATCHES = new ArrayList<>();
|
||||
|
||||
private PatchRegistry() {
|
||||
}
|
||||
|
||||
public static void register(Class<?> patchClass) {
|
||||
Patch annotation = patchClass.getAnnotation(Patch.class);
|
||||
if (annotation == null) {
|
||||
throw new IllegalArgumentException(patchClass.getName() + " is missing @Patch");
|
||||
}
|
||||
synchronized (PATCHES) {
|
||||
if (!PATCHES.contains(patchClass)) {
|
||||
PATCHES.add(patchClass);
|
||||
LOGGER.debug("Registered patch {} -> {}", patchClass.getName(), annotation.value().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Class<?>> getPatches() {
|
||||
synchronized (PATCHES) {
|
||||
return Collections.unmodifiableList(new ArrayList<>(PATCHES));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
package asm.patchify.loader;
|
||||
|
||||
import asm.patchify.annotation.At;
|
||||
import asm.patchify.annotation.Inject;
|
||||
import asm.patchify.annotation.Local;
|
||||
import asm.patchify.annotation.ModifyLocals;
|
||||
import asm.patchify.annotation.Overwrite;
|
||||
import asm.patchify.annotation.Patch;
|
||||
import asm.patchify.annotation.Slice;
|
||||
import asm.patchify.annotation.Transform;
|
||||
import asm.patchify.annotation.WrapInvoke;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.FieldInsnNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.JumpInsnNode;
|
||||
import org.objectweb.asm.tree.LabelNode;
|
||||
import org.objectweb.asm.tree.LdcInsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
import shit.zen.asm.Bootstrap;
|
||||
import shit.zen.asm.ILocals;
|
||||
import shit.zen.asm.Invocation;
|
||||
import shit.zen.asm.InvocationImpl;
|
||||
import shit.zen.asm.Locals;
|
||||
import shit.zen.asm.MethodWrapper;
|
||||
import shit.zen.patch.CallbackInfo;
|
||||
|
||||
/**
|
||||
* Applies all {@link Patch}-annotated handlers from a patch class to a target {@link ClassNode}.
|
||||
* Ported from <a href="https://github.com/xiaojiang233/izmk-reborn">izmk-reborn</a>'s
|
||||
* {@code PatchLoader}.
|
||||
*
|
||||
* <p>Supports:</p>
|
||||
* <ul>
|
||||
* <li>{@link Inject} with {@link At.Type#HEAD}/{@link At.Type#TAIL}/
|
||||
* {@link At.Type#BEFORE_INVOKE}/{@link At.Type#AFTER_INVOKE} + {@link Slice}</li>
|
||||
* <li>{@link Overwrite}</li>
|
||||
* <li>{@link Transform} — direct {@link MethodNode} access for hand-written ASM</li>
|
||||
* <li>{@link WrapInvoke} — wrap an {@code INVOKE} with an {@link Invocation} continuation</li>
|
||||
* <li>{@link ModifyLocals} — read/write local slots via {@link ILocals}</li>
|
||||
* <li>{@link Local} — pass a method-local slot directly to a handler parameter</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PatchTransformer {
|
||||
private static final Logger LOGGER = LogManager.getLogger(PatchTransformer.class);
|
||||
private static final String CALLBACK_INFO = Type.getInternalName(CallbackInfo.class);
|
||||
private static final String CALLBACK_INFO_DESC = Type.getDescriptor(CallbackInfo.class);
|
||||
|
||||
private PatchTransformer() {
|
||||
}
|
||||
|
||||
public static void apply(Class<?> patchClass, ClassNode target) {
|
||||
Patch patchAnnotation = patchClass.getAnnotation(Patch.class);
|
||||
if (patchAnnotation == null) {
|
||||
throw new IllegalArgumentException(patchClass.getName() + " is not @Patch");
|
||||
}
|
||||
String patchTargetOwner;
|
||||
if (!patchAnnotation.className().isEmpty()) {
|
||||
patchTargetOwner = patchAnnotation.className().replace('.', '/');
|
||||
} else {
|
||||
patchTargetOwner = Type.getInternalName(patchAnnotation.value());
|
||||
}
|
||||
|
||||
Map<MethodKey, List<Method>> handlersByTarget = new HashMap<>();
|
||||
// Collect handlers whose desc is empty (name-only wildcards) separately
|
||||
List<Method> nameOnlyHandlers = new ArrayList<>();
|
||||
for (Method handler : patchClass.getDeclaredMethods()) {
|
||||
collectHandler(patchClass, patchTargetOwner, handler, handlersByTarget, nameOnlyHandlers);
|
||||
}
|
||||
LOGGER.info("Loading patch {} -> {} ({} handler(s))",
|
||||
patchClass.getName(), target.name,
|
||||
handlersByTarget.values().stream().mapToInt(List::size).sum() + nameOnlyHandlers.size());
|
||||
|
||||
Set<MethodKey> matched = new HashSet<>();
|
||||
Set<String> nameOnlyMatched = new HashSet<>();
|
||||
for (MethodNode method : target.methods) {
|
||||
MethodKey key = new MethodKey(method.name, method.desc);
|
||||
List<Method> handlers = handlersByTarget.get(key);
|
||||
if (handlers == null && !nameOnlyHandlers.isEmpty()) {
|
||||
// Try name-only match for handlers registered with empty desc.
|
||||
// Works for every handler kind (Inject/Overwrite/Transform/WrapInvoke/
|
||||
// ModifyLocals), not just Inject/Overwrite.
|
||||
for (Method candidate : nameOnlyHandlers) {
|
||||
String targetName = getHandlerTargetMethodName(candidate);
|
||||
if (targetName.equals(method.name)) {
|
||||
if (handlers == null) handlers = new ArrayList<>();
|
||||
handlers.add(candidate);
|
||||
nameOnlyMatched.add(method.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (handlers == null) continue;
|
||||
matched.add(key);
|
||||
for (Method handler : handlers) {
|
||||
if (handler.isAnnotationPresent(Inject.class)) {
|
||||
applyInject(method, handler);
|
||||
} else if (handler.isAnnotationPresent(Overwrite.class)) {
|
||||
overwriteMethod(method, handler);
|
||||
LOGGER.info("@Overwrite {}/{}{} <- {}#{}",
|
||||
target.name, method.name, method.desc,
|
||||
patchClass.getName(), handler.getName());
|
||||
} else if (handler.isAnnotationPresent(Transform.class)) {
|
||||
try {
|
||||
handler.setAccessible(true);
|
||||
handler.invoke(null, method);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to apply @Transform " + handler, e);
|
||||
}
|
||||
LOGGER.info("@Transform {}/{}{} <- {}#{}",
|
||||
target.name, method.name, method.desc,
|
||||
patchClass.getName(), handler.getName());
|
||||
} else if (handler.isAnnotationPresent(WrapInvoke.class)) {
|
||||
wrapInvoke(method, handler);
|
||||
} else if (handler.isAnnotationPresent(ModifyLocals.class)) {
|
||||
modifyLocals(method, handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<MethodKey, List<Method>> entry : handlersByTarget.entrySet()) {
|
||||
if (matched.contains(entry.getKey())) continue;
|
||||
for (Method handler : entry.getValue()) {
|
||||
LOGGER.warn("Patch handler {}#{}{} targets {}#{}{} but no such method exists on {} — handler will not run.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(), Type.getMethodDescriptor(handler),
|
||||
target.name, entry.getKey().name(), entry.getKey().desc(), target.name);
|
||||
}
|
||||
}
|
||||
// Warn about unmatched name-only handlers
|
||||
for (Method handler : nameOnlyHandlers) {
|
||||
if (!nameOnlyMatched.contains(getHandlerTargetMethodName(handler))) {
|
||||
LOGGER.warn("Patch handler {}#{} is name-only (empty desc) but no method named \"{}\" exists on {} — handler will not run.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(),
|
||||
getHandlerTargetMethodName(handler), target.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String getHandlerTargetMethodName(Method handler) {
|
||||
Inject inject = handler.getAnnotation(Inject.class);
|
||||
if (inject != null) return inject.method();
|
||||
Overwrite overwrite = handler.getAnnotation(Overwrite.class);
|
||||
if (overwrite != null) return overwrite.method();
|
||||
Transform transform = handler.getAnnotation(Transform.class);
|
||||
if (transform != null) return transform.method();
|
||||
WrapInvoke wrap = handler.getAnnotation(WrapInvoke.class);
|
||||
if (wrap != null) return wrap.method();
|
||||
ModifyLocals modify = handler.getAnnotation(ModifyLocals.class);
|
||||
if (modify != null) return modify.method();
|
||||
return "?";
|
||||
}
|
||||
|
||||
private static void collectHandler(Class<?> patchClass, String patchTargetOwner,
|
||||
Method handler,
|
||||
Map<MethodKey, List<Method>> handlersByTarget,
|
||||
List<Method> nameOnlyHandlers) {
|
||||
if (!(handler.isAnnotationPresent(Inject.class)
|
||||
|| handler.isAnnotationPresent(Overwrite.class)
|
||||
|| handler.isAnnotationPresent(Transform.class)
|
||||
|| handler.isAnnotationPresent(WrapInvoke.class)
|
||||
|| handler.isAnnotationPresent(ModifyLocals.class))) {
|
||||
return;
|
||||
}
|
||||
if (!Modifier.isStatic(handler.getModifiers())) {
|
||||
throw new IllegalArgumentException("Handler " + handler + " must be static");
|
||||
}
|
||||
|
||||
String name;
|
||||
String desc;
|
||||
if (handler.isAnnotationPresent(Inject.class)) {
|
||||
Inject inject = handler.getAnnotation(Inject.class);
|
||||
name = inject.method();
|
||||
desc = inject.desc();
|
||||
validateInjectSignature(patchClass, handler, inject);
|
||||
} else if (handler.isAnnotationPresent(Overwrite.class)) {
|
||||
Overwrite overwrite = handler.getAnnotation(Overwrite.class);
|
||||
name = overwrite.method();
|
||||
desc = overwrite.desc();
|
||||
} else if (handler.isAnnotationPresent(Transform.class)) {
|
||||
Transform transform = handler.getAnnotation(Transform.class);
|
||||
name = transform.method();
|
||||
desc = transform.desc();
|
||||
if (handler.getParameterCount() != 1 || handler.getParameterTypes()[0] != MethodNode.class) {
|
||||
throw new IllegalArgumentException("@Transform " + handler + " must take a single MethodNode");
|
||||
}
|
||||
} else if (handler.isAnnotationPresent(WrapInvoke.class)) {
|
||||
WrapInvoke wrap = handler.getAnnotation(WrapInvoke.class);
|
||||
name = wrap.method();
|
||||
desc = wrap.desc();
|
||||
Class<?>[] params = handler.getParameterTypes();
|
||||
if (params.length == 0 || !Invocation.class.isAssignableFrom(params[params.length - 1])) {
|
||||
throw new IllegalArgumentException("@WrapInvoke " + handler + " must take an Invocation as last param");
|
||||
}
|
||||
} else {
|
||||
ModifyLocals modify = handler.getAnnotation(ModifyLocals.class);
|
||||
name = modify.method();
|
||||
desc = modify.desc();
|
||||
if (handler.getParameterCount() != 1 || !ILocals.class.isAssignableFrom(handler.getParameterTypes()[0])) {
|
||||
throw new IllegalArgumentException("@ModifyLocals " + handler + " must take a single ILocals");
|
||||
}
|
||||
}
|
||||
// Patch annotations carry the mojmap method name (the jar was compiled
|
||||
// against mojmap, and reobfJar does not rewrite string literals). In a
|
||||
// production Forge environment the live class only has SRG names, so
|
||||
// remap before matching against ClassNode.methods.
|
||||
name = Bootstrap.remapMethod(patchTargetOwner, name, desc);
|
||||
if (desc.isEmpty()) {
|
||||
// Empty desc = match by method name only (wildcard for mod classes
|
||||
// whose exact descriptor may vary between Yarn and Mojmap mappings).
|
||||
nameOnlyHandlers.add(handler);
|
||||
} else {
|
||||
handlersByTarget.computeIfAbsent(new MethodKey(name, desc), k -> new ArrayList<>()).add(handler);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateInjectSignature(Class<?> patchClass, Method handler, Inject inject) {
|
||||
if (handler.getReturnType() != void.class) {
|
||||
throw new IllegalArgumentException("@Inject " + handler + " must return void");
|
||||
}
|
||||
Parameter[] params = handler.getParameters();
|
||||
if (params.length == 0 || params[params.length - 1].getType() != CallbackInfo.class) {
|
||||
throw new IllegalArgumentException("@Inject " + handler + " must take CallbackInfo as last param");
|
||||
}
|
||||
if (inject.at().value() == At.Type.HEAD) {
|
||||
for (Parameter param : params) {
|
||||
if (param.isAnnotationPresent(Local.class)) {
|
||||
throw new IllegalArgumentException("@Inject " + handler + " HEAD cannot use @Local");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @Inject
|
||||
// ============================================================================
|
||||
|
||||
private static void applyInject(MethodNode method, Method handler) {
|
||||
At at = handler.getAnnotation(Inject.class).at();
|
||||
switch (at.value()) {
|
||||
case HEAD -> injectHead(method, handler);
|
||||
case TAIL -> injectTail(method, handler);
|
||||
case BEFORE_INVOKE, AFTER_INVOKE -> {
|
||||
String invokeName = at.method();
|
||||
String invokeDesc = at.desc();
|
||||
if (invokeName.isEmpty() || invokeDesc.isEmpty()) {
|
||||
throw new IllegalArgumentException("@At " + handler + " missing method/desc");
|
||||
}
|
||||
injectAroundInvoke(method, handler, invokeName, invokeDesc, at.value() == At.Type.BEFORE_INVOKE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a HEAD-inject handler's parameter list is bytecode-compatible with the
|
||||
* {@code (receiver?, args..., CallbackInfo)} sequence that {@link #injectHead} forwards.
|
||||
*
|
||||
* <p>This matters most for name-only ({@code desc=""}) patches: those match a target by
|
||||
* method name alone, so a handler can end up bound to a method whose arity or primitive
|
||||
* parameter types differ from what it declares. Forwarding into such a handler would emit a
|
||||
* call site that fails JVM verification ({@code VerifyError}) at class load. Returns
|
||||
* {@code null} when compatible, otherwise a human-readable reason.</p>
|
||||
*/
|
||||
private static String headHandlerMismatch(MethodNode method, Method handler) {
|
||||
List<Type> forwarded = new ArrayList<>();
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
// Receiver is always a reference; only its primitive-ness is checked below.
|
||||
forwarded.add(Type.getObjectType("java/lang/Object"));
|
||||
}
|
||||
forwarded.addAll(Arrays.asList(Type.getArgumentTypes(method.desc)));
|
||||
|
||||
// The last handler parameter is CallbackInfo (enforced by validateInjectSignature);
|
||||
// the remaining params receive the forwarded receiver + args.
|
||||
int expected = handler.getParameterCount() - 1;
|
||||
if (forwarded.size() != expected) {
|
||||
return "handler takes " + expected + " forwarded parameter(s) but target supplies "
|
||||
+ forwarded.size() + " (receiver + args)";
|
||||
}
|
||||
Class<?>[] params = handler.getParameterTypes();
|
||||
for (int i = 0; i < forwarded.size(); i++) {
|
||||
Type arg = forwarded.get(i);
|
||||
Class<?> p = params[i];
|
||||
boolean argIsPrimitive = arg.getSort() != Type.OBJECT && arg.getSort() != Type.ARRAY;
|
||||
if (argIsPrimitive) {
|
||||
// Primitives cannot widen to Object — the handler param must be the exact type.
|
||||
if (!p.isPrimitive() || !p.getName().equals(arg.getClassName())) {
|
||||
return "parameter " + i + " is primitive " + arg.getClassName()
|
||||
+ " but handler declares " + p.getName();
|
||||
}
|
||||
} else if (p.isPrimitive()) {
|
||||
// A reference arg cannot be passed into a primitive parameter.
|
||||
return "parameter " + i + " is a reference but handler declares primitive " + p.getName();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void injectHead(MethodNode method, Method handler) {
|
||||
String mismatch = headHandlerMismatch(method, handler);
|
||||
if (mismatch != null) {
|
||||
LOGGER.warn("@Inject(HEAD) {}#{} is incompatible with matched target {}{} — {} — skipping injection to avoid invalid bytecode.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(),
|
||||
method.name, method.desc, mismatch);
|
||||
return;
|
||||
}
|
||||
Type returnType = Type.getReturnType(method.desc);
|
||||
String handlerOwner = Type.getInternalName(handler.getDeclaringClass());
|
||||
String handlerName = handler.getName();
|
||||
String handlerDesc = Type.getMethodDescriptor(handler);
|
||||
|
||||
InsnList insns = new InsnList();
|
||||
LabelNode notCancelled = new LabelNode();
|
||||
int callbackIndex = method.maxLocals;
|
||||
method.maxLocals += 1;
|
||||
|
||||
// Forward target method's receiver + args to the handler.
|
||||
int slot = 0;
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, slot++));
|
||||
}
|
||||
for (Type argType : Type.getArgumentTypes(method.desc)) {
|
||||
insns.add(new VarInsnNode(argType.getOpcode(Opcodes.ILOAD), slot));
|
||||
slot += argType.getSize();
|
||||
}
|
||||
// CallbackInfo.create(null)
|
||||
insns.add(new InsnNode(Opcodes.ACONST_NULL));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CALLBACK_INFO, "create",
|
||||
"(Ljava/lang/Object;)" + CALLBACK_INFO_DESC, false));
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new VarInsnNode(Opcodes.ASTORE, callbackIndex));
|
||||
// Invoke handler
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, handlerOwner, handlerName, handlerDesc, false));
|
||||
// Check CallbackInfo.cancelled
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, callbackIndex));
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new FieldInsnNode(Opcodes.GETFIELD, CALLBACK_INFO, "cancelled", "Z"));
|
||||
insns.add(new JumpInsnNode(Opcodes.IFEQ, notCancelled));
|
||||
// cancelled -> return result
|
||||
if (returnType == Type.VOID_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
insns.add(new InsnNode(Opcodes.RETURN));
|
||||
} else {
|
||||
insns.add(new FieldInsnNode(Opcodes.GETFIELD, CALLBACK_INFO, "result", "Ljava/lang/Object;"));
|
||||
insns.add(ASMHelpers.unboxFromObject(returnType));
|
||||
insns.add(new InsnNode(returnType.getOpcode(Opcodes.IRETURN)));
|
||||
}
|
||||
insns.add(notCancelled);
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
method.instructions.insert(insns);
|
||||
LOGGER.info("@Inject(HEAD) {}/{}{} <- {}#{}",
|
||||
targetClassName(handler), method.name, method.desc,
|
||||
handler.getDeclaringClass().getName(), handler.getName());
|
||||
}
|
||||
|
||||
private static void injectTail(MethodNode method, Method handler) {
|
||||
Type returnType = Type.getReturnType(method.desc);
|
||||
int returnOp = returnType.getOpcode(Opcodes.IRETURN);
|
||||
Slice slice = handler.getAnnotation(Inject.class).slice();
|
||||
List<AbstractInsnNode> returnInsns = collectInjectionPoints(method.instructions, slice,
|
||||
insn -> insn.getOpcode() == returnOp);
|
||||
if (returnInsns.isEmpty()) {
|
||||
LOGGER.warn("@Inject(TAIL) {}#{}{} found no return instruction in target {}{} — patch handler will not run.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(), Type.getMethodDescriptor(handler),
|
||||
method.name, method.desc);
|
||||
return;
|
||||
}
|
||||
|
||||
String handlerOwner = Type.getInternalName(handler.getDeclaringClass());
|
||||
String handlerName = handler.getName();
|
||||
String handlerDesc = Type.getMethodDescriptor(handler);
|
||||
|
||||
for (AbstractInsnNode returnInsn : returnInsns) {
|
||||
InsnList insns = new InsnList();
|
||||
int callbackIndex = method.maxLocals;
|
||||
method.maxLocals += 1;
|
||||
|
||||
// Wrap return value as Object and stash into CallbackInfo.result.
|
||||
if (returnType == Type.VOID_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.ACONST_NULL));
|
||||
} else {
|
||||
insns.add(ASMHelpers.boxToObject(returnType));
|
||||
}
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CALLBACK_INFO, "create",
|
||||
"(Ljava/lang/Object;)" + CALLBACK_INFO_DESC, false));
|
||||
|
||||
// Forward (this, args..., @Local..., CallbackInfo) to handler. CallbackInfo is on top of stack now.
|
||||
int slot = 0;
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, slot++));
|
||||
insns.add(new InsnNode(Opcodes.SWAP));
|
||||
}
|
||||
for (Type argType : Type.getArgumentTypes(method.desc)) {
|
||||
insns.add(new VarInsnNode(argType.getOpcode(Opcodes.ILOAD), slot));
|
||||
slot += argType.getSize();
|
||||
swapForCallback(argType, insns);
|
||||
}
|
||||
for (Parameter param : handler.getParameters()) {
|
||||
Local local = param.getAnnotation(Local.class);
|
||||
if (local == null) continue;
|
||||
Type type = Type.getType(param.getType());
|
||||
insns.add(new VarInsnNode(type.getOpcode(Opcodes.ILOAD), local.value()));
|
||||
swapForCallback(type, insns);
|
||||
}
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new VarInsnNode(Opcodes.ASTORE, callbackIndex));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, handlerOwner, handlerName, handlerDesc, false));
|
||||
|
||||
// Restore the (possibly modified) return value from CallbackInfo.result.
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, callbackIndex));
|
||||
insns.add(new FieldInsnNode(Opcodes.GETFIELD, CALLBACK_INFO, "result", "Ljava/lang/Object;"));
|
||||
if (returnType == Type.VOID_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
} else {
|
||||
insns.add(ASMHelpers.unboxFromObject(returnType));
|
||||
}
|
||||
method.instructions.insertBefore(returnInsn, insns);
|
||||
}
|
||||
LOGGER.info("@Inject(TAIL) {}/{}{} <- {}#{} ({} return site(s))",
|
||||
targetClassName(handler), method.name, method.desc,
|
||||
handler.getDeclaringClass().getName(), handler.getName(), returnInsns.size());
|
||||
}
|
||||
|
||||
private static void injectAroundInvoke(MethodNode method, Method handler,
|
||||
String invokeName, String invokeDesc, boolean before) {
|
||||
Type returnType = Type.getReturnType(method.desc);
|
||||
String[] split = ASMHelpers.splitOwnerName(invokeName);
|
||||
String invokeOwner = split[0];
|
||||
String invokeMethod = Bootstrap.remapMethod(split[0], split[1], invokeDesc);
|
||||
|
||||
Slice slice = handler.getAnnotation(Inject.class).slice();
|
||||
// PatchApplier.applyInvokeInject uses strict owner+name+desc matching.
|
||||
List<AbstractInsnNode> sites = collectInjectionPoints(method.instructions, slice,
|
||||
insn -> insn instanceof MethodInsnNode m
|
||||
&& m.owner.equals(invokeOwner)
|
||||
&& m.name.equals(invokeMethod)
|
||||
&& m.desc.equals(invokeDesc));
|
||||
if (sites.isEmpty()) {
|
||||
LOGGER.warn("@Inject({}_INVOKE) {}#{}{} found no call site of {}#{}{} — patch handler will not run.",
|
||||
before ? "BEFORE" : "AFTER",
|
||||
handler.getDeclaringClass().getName(), handler.getName(), Type.getMethodDescriptor(handler),
|
||||
invokeOwner, invokeMethod, invokeDesc);
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Integer> initializedLocals = collectInitializedLocalsBefore(method, sites.get(0));
|
||||
int callbackIndex = method.maxLocals;
|
||||
method.maxLocals += 1;
|
||||
|
||||
String handlerOwner = Type.getInternalName(handler.getDeclaringClass());
|
||||
String handlerName = handler.getName();
|
||||
String handlerDesc = Type.getMethodDescriptor(handler);
|
||||
|
||||
for (AbstractInsnNode site : sites) {
|
||||
InsnList insns = new InsnList();
|
||||
LabelNode notCancelled = new LabelNode();
|
||||
|
||||
int slot = 0;
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, slot++));
|
||||
}
|
||||
for (Type argType : Type.getArgumentTypes(method.desc)) {
|
||||
insns.add(new VarInsnNode(argType.getOpcode(Opcodes.ILOAD), slot));
|
||||
slot += argType.getSize();
|
||||
}
|
||||
for (Parameter param : handler.getParameters()) {
|
||||
Local local = param.getAnnotation(Local.class);
|
||||
if (local == null) continue;
|
||||
if (!initializedLocals.contains(local.value())) {
|
||||
throw new IllegalArgumentException("@Local index " + local.value() + " not initialized before injection point in "
|
||||
+ handler.getDeclaringClass().getName() + "." + handler.getName());
|
||||
}
|
||||
Type type = Type.getType(param.getType());
|
||||
insns.add(new VarInsnNode(type.getOpcode(Opcodes.ILOAD), local.value()));
|
||||
}
|
||||
insns.add(new InsnNode(Opcodes.ACONST_NULL));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CALLBACK_INFO, "create",
|
||||
"(Ljava/lang/Object;)" + CALLBACK_INFO_DESC, false));
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new VarInsnNode(Opcodes.ASTORE, callbackIndex));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, handlerOwner, handlerName, handlerDesc, false));
|
||||
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, callbackIndex));
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new FieldInsnNode(Opcodes.GETFIELD, CALLBACK_INFO, "cancelled", "Z"));
|
||||
insns.add(new JumpInsnNode(Opcodes.IFEQ, notCancelled));
|
||||
insns.add(new FieldInsnNode(Opcodes.GETFIELD, CALLBACK_INFO, "result", "Ljava/lang/Object;"));
|
||||
if (returnType == Type.VOID_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
insns.add(new InsnNode(Opcodes.RETURN));
|
||||
} else {
|
||||
insns.add(ASMHelpers.unboxFromObject(returnType));
|
||||
insns.add(new InsnNode(returnType.getOpcode(Opcodes.IRETURN)));
|
||||
}
|
||||
insns.add(notCancelled);
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
|
||||
if (before) {
|
||||
method.instructions.insertBefore(site, insns);
|
||||
} else {
|
||||
method.instructions.insert(site, insns);
|
||||
}
|
||||
}
|
||||
LOGGER.info("@Inject({}) {}/{}{} <- {}#{} around {}#{}{} ({} site(s))",
|
||||
before ? "BEFORE_INVOKE" : "AFTER_INVOKE ",
|
||||
targetClassName(handler), method.name, method.desc,
|
||||
handler.getDeclaringClass().getName(), handler.getName(),
|
||||
invokeOwner, invokeMethod, invokeDesc, sites.size());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @Overwrite
|
||||
// ============================================================================
|
||||
|
||||
private static void overwriteMethod(MethodNode method, Method handler) {
|
||||
int expectedParams = Type.getArgumentTypes(method.desc).length
|
||||
+ (Modifier.isStatic(method.access) ? 0 : 1);
|
||||
if (handler.getParameterCount() != expectedParams) {
|
||||
throw new IllegalArgumentException(
|
||||
"@Overwrite handler " + handler + " has " + handler.getParameterCount()
|
||||
+ " params but target requires " + expectedParams
|
||||
+ " ((this if non-static), then method args). Remove any CallbackInfo param.");
|
||||
}
|
||||
// PatchApplier.applyOverwrite: prepend a static call to the handler followed by
|
||||
// RETURN. The original instructions stay as dead code (never reached). Slot
|
||||
// increment is plain ++ — wide types after a wide type are not handled because
|
||||
// PatchApplier never handled them.
|
||||
InsnList insns = new InsnList();
|
||||
int slot = 0;
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, slot++));
|
||||
}
|
||||
for (Type argType : Type.getArgumentTypes(method.desc)) {
|
||||
insns.add(new VarInsnNode(argType.getOpcode(Opcodes.ILOAD), slot++));
|
||||
}
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(handler.getDeclaringClass()),
|
||||
handler.getName(),
|
||||
Type.getMethodDescriptor(handler),
|
||||
false));
|
||||
Type returnType = Type.getReturnType(method.desc);
|
||||
if (returnType == Type.VOID_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.RETURN));
|
||||
} else {
|
||||
insns.add(new InsnNode(returnType.getOpcode(Opcodes.IRETURN)));
|
||||
}
|
||||
method.instructions.insert(insns);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @WrapInvoke
|
||||
// ============================================================================
|
||||
|
||||
private static void wrapInvoke(MethodNode method, Method handler) {
|
||||
WrapInvoke wrap = handler.getAnnotation(WrapInvoke.class);
|
||||
String target = wrap.target();
|
||||
String targetDesc = wrap.targetDesc();
|
||||
String[] split = ASMHelpers.splitOwnerName(target);
|
||||
String targetOwner = split[0];
|
||||
String targetMethod = Bootstrap.remapMethod(split[0], split[1], targetDesc);
|
||||
|
||||
// Prefer a strict owner+name+desc match. Falling back to the historical
|
||||
// (owner || name) matcher means a wrap aimed at Mth.lerp(FFF)F also gets
|
||||
// its slice index counted against every Mth.rotLerp(FFF)F call site,
|
||||
// which silently shifts indices once an earlier wrap deletes its own
|
||||
// site instruction. Strict matching keeps each wrap's slice index
|
||||
// independent. The legacy loose matcher is still used as a fallback so
|
||||
// patches that target an inherited method on a subclass receiver
|
||||
// (Entity#getYRot resolved at LivingEntity#getYRot) keep working.
|
||||
List<AbstractInsnNode> sites = collectInjectionPoints(method.instructions, wrap.slice(),
|
||||
insn -> insn instanceof MethodInsnNode m
|
||||
&& m.owner.equals(targetOwner)
|
||||
&& m.name.equals(targetMethod)
|
||||
&& m.desc.equals(targetDesc));
|
||||
if (sites.isEmpty()) {
|
||||
sites = collectInjectionPoints(method.instructions, wrap.slice(),
|
||||
insn -> insn instanceof MethodInsnNode m
|
||||
&& (m.owner.equals(targetOwner) || m.name.equals(targetMethod))
|
||||
&& m.desc.equals(targetDesc));
|
||||
}
|
||||
if (sites.isEmpty()) {
|
||||
LOGGER.warn("@WrapInvoke {}#{}{} found no call site of {}#{}{} — patch handler will not run.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(), Type.getMethodDescriptor(handler),
|
||||
targetOwner, targetMethod, targetDesc);
|
||||
return;
|
||||
}
|
||||
Set<Integer> initializedLocals = collectInitializedLocalsBefore(method, sites.get(0));
|
||||
|
||||
String handlerOwner = Type.getInternalName(handler.getDeclaringClass());
|
||||
String handlerName = handler.getName();
|
||||
String handlerDesc = Type.getMethodDescriptor(handler);
|
||||
|
||||
// PatchApplier looks at sites.get(0) once and reuses staticCall for the loop;
|
||||
// only INVOKESTATIC / INVOKEVIRTUAL are accepted.
|
||||
int firstOp = sites.get(0).getOpcode();
|
||||
boolean staticCall;
|
||||
if (firstOp == Opcodes.INVOKESTATIC) {
|
||||
staticCall = true;
|
||||
} else if (firstOp == Opcodes.INVOKEVIRTUAL) {
|
||||
staticCall = false;
|
||||
} else {
|
||||
throw new IllegalArgumentException("@WrapInvoke unsupported opcode " + firstOp + " in " + handler);
|
||||
}
|
||||
|
||||
for (AbstractInsnNode site : sites) {
|
||||
|
||||
InsnList insns = new InsnList();
|
||||
int wrapperLocal = method.maxLocals;
|
||||
method.maxLocals += 1;
|
||||
|
||||
// Build MethodWrapper instance and stash it in a local.
|
||||
insns.add(new LdcInsnNode(targetOwner));
|
||||
insns.add(new LdcInsnNode(targetMethod));
|
||||
insns.add(new LdcInsnNode(targetDesc));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(MethodWrapper.class), "getInstance",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)" + Type.getDescriptor(MethodWrapper.class),
|
||||
false));
|
||||
insns.add(new VarInsnNode(Opcodes.ASTORE, wrapperLocal));
|
||||
|
||||
// Stack: [..., receiver?, args...]
|
||||
// Pop args in reverse, box them, push them into MethodWrapper.
|
||||
Type[] argTypes = Type.getArgumentTypes(targetDesc);
|
||||
for (int i = argTypes.length - 1; i >= 0; i--) {
|
||||
insns.add(ASMHelpers.boxToObject(argTypes[i]));
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, wrapperLocal));
|
||||
insns.add(new InsnNode(Opcodes.SWAP));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL,
|
||||
Type.getInternalName(MethodWrapper.class), "addParam",
|
||||
"(Ljava/lang/Object;)" + Type.getDescriptor(MethodWrapper.class), false));
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
}
|
||||
|
||||
// Build the InvocationImpl. For non-static calls the receiver is still on the stack.
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, wrapperLocal));
|
||||
if (staticCall) {
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(InvocationImpl.class), "create",
|
||||
"(" + Type.getDescriptor(MethodWrapper.class) + ")" + Type.getDescriptor(InvocationImpl.class),
|
||||
false));
|
||||
} else {
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(InvocationImpl.class), "create",
|
||||
"(Ljava/lang/Object;" + Type.getDescriptor(MethodWrapper.class) + ")" + Type.getDescriptor(InvocationImpl.class),
|
||||
false));
|
||||
}
|
||||
// Stack now: [..., Invocation]
|
||||
|
||||
// Forward (this, methodArgs..., @Local..., Invocation) to handler.
|
||||
int slot = 0;
|
||||
if (!Modifier.isStatic(method.access)) {
|
||||
insns.add(new VarInsnNode(Opcodes.ALOAD, 0));
|
||||
insns.add(new InsnNode(Opcodes.SWAP));
|
||||
slot++;
|
||||
}
|
||||
for (Type argType : Type.getArgumentTypes(method.desc)) {
|
||||
insns.add(new VarInsnNode(argType.getOpcode(Opcodes.ILOAD), slot));
|
||||
slot += argType.getSize();
|
||||
swapForCallback(argType, insns);
|
||||
}
|
||||
for (Parameter param : handler.getParameters()) {
|
||||
Local local = param.getAnnotation(Local.class);
|
||||
if (local == null) continue;
|
||||
if (!initializedLocals.contains(local.value())) {
|
||||
throw new IllegalArgumentException("@Local index " + local.value() + " not initialized in " + handler);
|
||||
}
|
||||
Type type = Type.getType(param.getType());
|
||||
insns.add(new VarInsnNode(type.getOpcode(Opcodes.ILOAD), local.value()));
|
||||
swapForCallback(type, insns);
|
||||
}
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC, handlerOwner, handlerName, handlerDesc, false));
|
||||
|
||||
method.instructions.insertBefore(site, insns);
|
||||
method.instructions.remove(site);
|
||||
}
|
||||
LOGGER.info("@WrapInvoke {}/{}{} <- {}#{} wraps {}#{}{} ({} site(s))",
|
||||
targetClassName(handler), method.name, method.desc,
|
||||
handler.getDeclaringClass().getName(), handler.getName(),
|
||||
targetOwner, targetMethod, targetDesc, sites.size());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// @ModifyLocals
|
||||
// ============================================================================
|
||||
|
||||
private static void modifyLocals(MethodNode method, Method handler) {
|
||||
ModifyLocals modify = handler.getAnnotation(ModifyLocals.class);
|
||||
int[] indexes = modify.indexes();
|
||||
Class<?>[] typeClasses = modify.types();
|
||||
if (indexes.length != typeClasses.length) {
|
||||
throw new IllegalArgumentException("@ModifyLocals indexes/types length mismatch in " + handler);
|
||||
}
|
||||
Type[] types = new Type[typeClasses.length];
|
||||
for (int i = 0; i < typeClasses.length; i++) {
|
||||
types[i] = Type.getType(typeClasses[i]);
|
||||
}
|
||||
|
||||
At at = modify.at();
|
||||
AbstractInsnNode insertBefore = method.instructions.getFirst();
|
||||
Set<Integer> initializedLocals = new HashSet<>();
|
||||
if (at.value() != At.Type.HEAD) {
|
||||
boolean foundAnchor = false;
|
||||
if (at.value() == At.Type.TAIL) {
|
||||
int retOp = Type.getReturnType(method.desc).getOpcode(Opcodes.IRETURN);
|
||||
for (AbstractInsnNode insn : method.instructions) {
|
||||
if (insn.getOpcode() == retOp) {
|
||||
insertBefore = insn;
|
||||
foundAnchor = true;
|
||||
break;
|
||||
}
|
||||
if (insn instanceof VarInsnNode v && v.getOpcode() >= Opcodes.ISTORE && v.getOpcode() <= Opcodes.ASTORE) {
|
||||
initializedLocals.add(v.var);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String[] split = ASMHelpers.splitOwnerName(at.method());
|
||||
String invokeMethod = Bootstrap.remapMethod(split[0], split[1], at.desc());
|
||||
for (AbstractInsnNode insn : method.instructions) {
|
||||
if (insn instanceof MethodInsnNode m
|
||||
&& m.owner.equals(split[0])
|
||||
&& m.name.equals(invokeMethod)
|
||||
&& m.desc.equals(at.desc())) {
|
||||
insertBefore = insn;
|
||||
foundAnchor = true;
|
||||
break;
|
||||
}
|
||||
if (insn instanceof VarInsnNode v && v.getOpcode() >= Opcodes.ISTORE && v.getOpcode() <= Opcodes.ASTORE) {
|
||||
initializedLocals.add(v.var);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundAnchor) {
|
||||
LOGGER.warn("@ModifyLocals {}#{}{} found no anchor {} {}{} in target {}{} — patch handler will not run.",
|
||||
handler.getDeclaringClass().getName(), handler.getName(), Type.getMethodDescriptor(handler),
|
||||
at.value(), at.method(), at.desc(),
|
||||
method.name, method.desc);
|
||||
return;
|
||||
}
|
||||
for (int idx : indexes) {
|
||||
if (!initializedLocals.contains(idx) && at.value() != At.Type.HEAD) {
|
||||
// Don't hard-fail — caller might intentionally read an uninitialised slot.
|
||||
// Just log via exception only if BEFORE/AFTER were used.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InsnList insns = new InsnList();
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(Locals.class), "create",
|
||||
"()" + Type.getDescriptor(Locals.class), false));
|
||||
for (int i = 0; i < indexes.length; i++) {
|
||||
int idx = indexes[i];
|
||||
Type type = types[i];
|
||||
insns.add(new LdcInsnNode(idx));
|
||||
insns.add(new VarInsnNode(type.getOpcode(Opcodes.ILOAD), idx));
|
||||
insns.add(ASMHelpers.boxToObject(type));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE,
|
||||
Type.getInternalName(ILocals.class), "set",
|
||||
"(ILjava/lang/Object;)" + Type.getDescriptor(ILocals.class), true));
|
||||
}
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKESTATIC,
|
||||
Type.getInternalName(handler.getDeclaringClass()),
|
||||
handler.getName(), Type.getMethodDescriptor(handler), false));
|
||||
for (int i = 0; i < indexes.length; i++) {
|
||||
int idx = indexes[i];
|
||||
Type type = types[i];
|
||||
insns.add(new InsnNode(Opcodes.DUP));
|
||||
insns.add(new LdcInsnNode(idx));
|
||||
insns.add(new MethodInsnNode(Opcodes.INVOKEINTERFACE,
|
||||
Type.getInternalName(ILocals.class), "get",
|
||||
"(I)Ljava/lang/Object;", true));
|
||||
insns.add(ASMHelpers.unboxFromObject(type));
|
||||
insns.add(new VarInsnNode(type.getOpcode(Opcodes.ISTORE), idx));
|
||||
}
|
||||
insns.add(new InsnNode(Opcodes.POP));
|
||||
method.instructions.insertBefore(insertBefore, insns);
|
||||
LOGGER.info("@ModifyLocals {}/{}{} <- {}#{} (locals: {})",
|
||||
targetClassName(handler), method.name, method.desc,
|
||||
handler.getDeclaringClass().getName(), handler.getName(),
|
||||
Arrays.toString(indexes));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
private static String targetClassName(Method handler) {
|
||||
Patch patch = handler.getDeclaringClass().getAnnotation(Patch.class);
|
||||
if (patch == null) return "?";
|
||||
if (!patch.className().isEmpty()) return patch.className().replace('.', '/');
|
||||
return Type.getInternalName(patch.value());
|
||||
}
|
||||
|
||||
private static List<AbstractInsnNode> collectInjectionPoints(InsnList insns, Slice slice,
|
||||
Predicate<AbstractInsnNode> filter) {
|
||||
boolean defaultStart = slice.start().value() == At.Type.HEAD;
|
||||
boolean defaultEnd = slice.end().value() == At.Type.TAIL;
|
||||
boolean indexed = slice.startIndex() != -1 || slice.endIndex() != -1;
|
||||
List<AbstractInsnNode> matches = new ArrayList<>();
|
||||
|
||||
if (defaultStart && defaultEnd && !indexed) {
|
||||
for (AbstractInsnNode insn : insns) {
|
||||
if (filter.test(insn)) matches.add(insn);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
if (indexed) {
|
||||
int count = 0;
|
||||
int endIndex = slice.endIndex() == -1 ? Integer.MAX_VALUE : slice.endIndex();
|
||||
int startIndex = slice.startIndex() == -1 ? 1 : slice.startIndex();
|
||||
for (AbstractInsnNode insn : insns) {
|
||||
if (!filter.test(insn)) continue;
|
||||
count++;
|
||||
if (count >= startIndex && count <= endIndex) {
|
||||
matches.add(insn);
|
||||
} else if (count > endIndex) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
// method-bounded slice
|
||||
String[] startSplit = slice.start().method().isEmpty()
|
||||
? null : ASMHelpers.splitOwnerName(slice.start().method());
|
||||
String startDesc = slice.start().desc();
|
||||
String startName = startSplit == null ? null
|
||||
: Bootstrap.remapMethod(startSplit[0], startSplit[1], startDesc);
|
||||
String[] endSplit = slice.end().method().isEmpty()
|
||||
? null : ASMHelpers.splitOwnerName(slice.end().method());
|
||||
String endDesc = slice.end().desc();
|
||||
String endName = endSplit == null ? null
|
||||
: Bootstrap.remapMethod(endSplit[0], endSplit[1], endDesc);
|
||||
boolean foundStart = defaultStart;
|
||||
for (AbstractInsnNode insn : insns) {
|
||||
if (!foundStart && startSplit != null && insn instanceof MethodInsnNode m
|
||||
&& m.owner.equals(startSplit[0]) && m.name.equals(startName) && m.desc.equals(startDesc)) {
|
||||
foundStart = true;
|
||||
} else if (!defaultEnd && endSplit != null && insn instanceof MethodInsnNode m
|
||||
&& m.owner.equals(endSplit[0]) && m.name.equals(endName) && m.desc.equals(endDesc)) {
|
||||
break;
|
||||
}
|
||||
// PatchApplier appends every insn in the slice, not only filter matches.
|
||||
// No production patch uses method-bounded slices today, so this faithfully
|
||||
// mirrors the original — but downstream code expecting filtered hits would
|
||||
// crash if a method-bounded slice were ever introduced.
|
||||
if (foundStart) {
|
||||
matches.add(insn);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static Set<Integer> collectInitializedLocalsBefore(MethodNode method, AbstractInsnNode boundary) {
|
||||
Set<Integer> locals = new HashSet<>();
|
||||
for (AbstractInsnNode insn : method.instructions) {
|
||||
if (insn == boundary) break;
|
||||
if (insn instanceof VarInsnNode v && v.getOpcode() >= Opcodes.ISTORE && v.getOpcode() <= Opcodes.ASTORE) {
|
||||
locals.add(v.var);
|
||||
}
|
||||
}
|
||||
return locals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap a value just pushed below the CallbackInfo / Invocation reference. Long/double take
|
||||
* two slots and need DUP2_X1+POP2 instead of SWAP. Mirrors izmk's helper.
|
||||
*/
|
||||
private static void swapForCallback(Type type, InsnList insns) {
|
||||
if (type == Type.LONG_TYPE || type == Type.DOUBLE_TYPE) {
|
||||
insns.add(new InsnNode(Opcodes.DUP2_X1));
|
||||
insns.add(new InsnNode(Opcodes.POP2));
|
||||
} else {
|
||||
insns.add(new InsnNode(Opcodes.SWAP));
|
||||
}
|
||||
}
|
||||
|
||||
private record MethodKey(String name, String desc) {
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return o instanceof MethodKey k && k.name.equals(name) && k.desc.equals(desc);
|
||||
}
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return name.hashCode() * 31 + desc.hashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppresses an unused-warning for {@link Arrays} import (kept for parity with izmk). */
|
||||
@SuppressWarnings("unused")
|
||||
private static void keepArraysImport() {
|
||||
Arrays.asList();
|
||||
}
|
||||
}
|
||||