Форк клиента собирается из исходников, модзагрузчик встроен в игру
Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - prepare-game.ps1 пересобирает клиент целиком: ванильный jar, SRG и MCP, SpecialSource, FernFlower, патчи, автофиксеры, javac. Идемпотентен. - Свой код игры лежит в src-overlay, потому что src пересоздаётся при каждой сборке и всё написанное вручную там погибает. - 116 патчей в patches/decompiler-fixes.txt. Каждый сверялся с оригинальным байткодом через javap: компиляция не ловит ошибки декомпиляции. - struct_diff.py сравнивает исходный байткод с нашей сборкой по классам, интерфейсам, суперклассам, методам и полям, и отдельно проверяет, что пропавшие классы действительно никому не нужны. Загрузчик - Собирается и тестируется без игры: build.ps1 не использует classpath вообще, поэтому SelfTest из 128 проверок проходит без запущенного форка. - Клиентский API не содержит ни одного игрового типа, реализацию даёт игра. - Политика: мод меняет то, что клиент показывает, но никогда то, что истинно в мире. Мир, предметы, игроки и сеть принадлежат серверу. - Capability приведены в соответствие с этой политикой: осталось шесть, и за каждым стоит реальная проверка. Прежние WORLD, PLAYER, NETWORK, TEXT, INPUT и COMMAND выдавались, но ничего не проверяли. - Команды модов имеют область CLIENT, SERVER или BOTH. Серверные не выполняются на клиенте никогда. - Свой jar загрузчика лежит в classpath игры и сборки форка. Инструменты - add_patch.py, чтобы каждое исправление переживало передекомпиляцию - find_self_comparisons.py, find_remove_index_bugs.py, find_callers.py - stage_run.py и fetch_missing.py для запуска клиента
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
# Compiles the forked client from the existing sources. Fast iteration loop:
|
||||
# fix, run this, look at the numbers. Use prepare-game.ps1 when the sources
|
||||
# themselves need regenerating.
|
||||
#
|
||||
# ASCII only: PowerShell 5.1 reads a .ps1 without a byte order mark using the
|
||||
# system ANSI code page.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Fork = Split-Path -Parent $PSScriptRoot
|
||||
if (-not $Fork) { $Fork = $PSCommandPath | Split-Path -Parent | Split-Path -Parent }
|
||||
$Root = Split-Path -Parent $Fork
|
||||
$Client = Join-Path $Root 'rsx-client'
|
||||
|
||||
$Astral = Join-Path $env:APPDATA 'AstralRinthApp\meta'
|
||||
$Src = Join-Path $Fork 'src'
|
||||
$Out = Join-Path $Fork 'build\classes'
|
||||
$Temp = Join-Path $env:TEMP 'opencode'
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$good = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '*.jar' -File)) {
|
||||
if ($j.Length -le 100) { continue }
|
||||
$ok = $false
|
||||
try { $z = [System.IO.Compression.ZipFile]::OpenRead($j.FullName); $z.Dispose(); $ok = $true } catch { $ok = $false }
|
||||
if ($ok) { $good.Add(($j.FullName -replace '\\', '/')) }
|
||||
}
|
||||
|
||||
# The fork calls into the mod loader, so the loader has to be visible to javac
|
||||
# exactly as it is at runtime. Building the loader first is what keeps the two
|
||||
# from drifting apart.
|
||||
$Loader = Join-Path $Client 'dist\rsx-loader.jar'
|
||||
|
||||
$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';'
|
||||
$argf = Join-Path $Temp 'fork-build.txt'
|
||||
$errf = Join-Path $Temp 'fork-build-errors.txt'
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
foreach ($a in @('-nowarn', '-proc:none', '-encoding', 'UTF-8', '--release', '17', '-d', ($Out -replace '\\', '/'))) { [void]$sb.AppendLine($a) }
|
||||
[void]$sb.AppendLine('-cp'); [void]$sb.AppendLine('"' + $cp + '"')
|
||||
Get-ChildItem $Src -Recurse -Filter '*.java' | ForEach-Object { [void]$sb.AppendLine(($_.FullName -replace '\\', '/')) }
|
||||
Set-Content -LiteralPath $argf -Value $sb.ToString() -Encoding ASCII
|
||||
|
||||
Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||
$javac = (Get-Command javac).Source
|
||||
& cmd /c "`"$javac`" @`"$argf`" -Xmaxerrs 10000 > `"$errf`" 2>&1"
|
||||
|
||||
$errors = Get-Content -LiteralPath $errf | Select-String -Pattern ': error:'
|
||||
$classes = (Get-ChildItem $Out -Recurse -Filter '*.class' -File -ErrorAction SilentlyContinue).Count
|
||||
|
||||
if ($errors.Count -eq 0) {
|
||||
Write-Host ''
|
||||
Write-Host " FORK COMPILES CLEAN - $classes classes" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
$files = $errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | Sort-Object -Unique
|
||||
Write-Host ''
|
||||
Write-Host " $classes classes, $($errors.Count) errors in $($files.Count) files" -ForegroundColor Yellow
|
||||
$errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } |
|
||||
Group-Object | Sort-Object Count -Descending | Select-Object -First 25 |
|
||||
ForEach-Object { Write-Host (" {0,4} {1}" -f $_.Count, ($_.Name.Replace(($Src + '\'), ''))) -ForegroundColor Gray }
|
||||
@@ -0,0 +1,173 @@
|
||||
# Builds the forked client from the verified vanilla jar.
|
||||
#
|
||||
# vanilla client -> MCP names -> source -> patches -> classes
|
||||
#
|
||||
# ASCII only: PowerShell 5.1 reads a .ps1 without a byte order mark using the
|
||||
# system ANSI code page, which mangles UTF-8 and breaks the parser.
|
||||
#
|
||||
# Everything here is repeatable. Run it any time; nothing is edited by hand
|
||||
# except game-fork\patches\decompiler-fixes.txt, which is re-applied at the end.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Fork = Split-Path -Parent $PSScriptRoot
|
||||
if (-not $Fork) { $Fork = $PSCommandPath | Split-Path -Parent | Split-Path -Parent }
|
||||
$Root = Split-Path -Parent $Fork
|
||||
$Client = Join-Path $Root 'rsx-client'
|
||||
|
||||
$VanillaJar = 'G:\rsx-map\vanilla\client-1.12.2.jar'
|
||||
$Astral = Join-Path $env:APPDATA 'AstralRinthApp\meta'
|
||||
$Fernflower = 'G:\Paper-Dev\work\BuildData\bin\fernflower.jar'
|
||||
$SpecialSource = 'G:\Paper-Dev\work\BuildData\bin\SpecialSource.jar'
|
||||
$McpCs = 'G:\rsx-map\vanilla\mcp-stable'
|
||||
|
||||
$Maps = Join-Path $Fork 'mappings'
|
||||
$Classes = Join-Path $Fork 'classes'
|
||||
$Src = Join-Path $Fork 'src'
|
||||
$Out = Join-Path $Fork 'build\classes'
|
||||
$Temp = Join-Path $env:TEMP 'opencode'
|
||||
|
||||
function Say($text) { Write-Host " $text" -ForegroundColor Cyan }
|
||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "missing $what at $path" } }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Maps, $Temp | Out-Null
|
||||
Need $VanillaJar 'the verified vanilla client jar'
|
||||
Need $Fernflower 'fernflower'
|
||||
Need $SpecialSource 'SpecialSource'
|
||||
Need $McpCs 'the MCP name CSVs'
|
||||
|
||||
# 1. vanilla SRG
|
||||
$srgZip = Join-Path $Maps 'mcp-1.12.2-srg.zip'
|
||||
$srgDir = Join-Path $Maps 'mcp-srg-vanilla'
|
||||
if (-not (Test-Path (Join-Path $srgDir 'joined.srg'))) {
|
||||
Say 'downloading the vanilla 1.12.2 SRG'
|
||||
Invoke-WebRequest -Uri 'https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.12.2/mcp-1.12.2-srg.zip' `
|
||||
-OutFile $srgZip -TimeoutSec 180 -UseBasicParsing
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($srgZip, $srgDir)
|
||||
}
|
||||
|
||||
# 2. merge SRG with the MCP CSVs into a single obf -> MCP SRG
|
||||
$srg = Join-Path $Maps 'obf-to-mcp.srg'
|
||||
Say 'merging SRG with the MCP CSVs'
|
||||
& python (Join-Path $Client 'tools\merge_srg_mcp.py') `
|
||||
(Join-Path $srgDir 'joined.srg') $McpCs $srg
|
||||
if ($LASTEXITCODE -ne 0) { throw 'srg merge failed' }
|
||||
|
||||
# 3. remap the vanilla client
|
||||
$named = Join-Path $Maps 'client-mcp-named.jar'
|
||||
Say 'remapping the vanilla client to MCP names'
|
||||
if (Test-Path $named) { Remove-Item $named -Force }
|
||||
& java -Xmx4g -jar $SpecialSource -m $srg -i $VanillaJar -o $named
|
||||
if (-not (Test-Path $named)) { throw 'remap produced nothing' }
|
||||
Say " client-mcp-named.jar ($((Get-Item $named).Length) bytes)"
|
||||
|
||||
# 4. unpack for the decompiler
|
||||
Remove-Item $Classes -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Classes | Out-Null
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($named, $Classes)
|
||||
Say " unpacked $((Get-ChildItem $Classes -Recurse -Filter '*.class' -File).Count) classes"
|
||||
|
||||
# 5. decompile
|
||||
# -dgs=1 decode generic signatures
|
||||
# -rbr=1 drop bridge methods (otherwise every generic method appears twice
|
||||
# and the compiler rejects the erasure clash)
|
||||
# -rsy=1 drop synthetic members
|
||||
Remove-Item $Src -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Src | Out-Null
|
||||
Say 'decompiling (this is the slow part)'
|
||||
$log = Join-Path $Temp 'fernflower.log'
|
||||
& cmd /c "java -Xmx4g -jar `"$Fernflower`" -dgs=1 -rbr=1 -rsy=1 `"$Classes`" `"$Src`" > `"$log`" 2>&1"
|
||||
Say " $((Get-ChildItem $Src -Recurse -Filter '*.java' -File).Count) source files"
|
||||
|
||||
# 5b. copy in our own game-side code
|
||||
#
|
||||
# Everything above this line is regenerated from the vanilla jar and is wiped on
|
||||
# every run. Our own classes under net/rustnotmc are not generated and must live
|
||||
# outside src, or a rebuild deletes them and leaves the patches that call them
|
||||
# pointing at nothing. They go in before the patches so a hand fix may edit them
|
||||
# too.
|
||||
$Overlay = Join-Path $Fork 'src-overlay'
|
||||
if (Test-Path $Overlay) {
|
||||
$copied = 0
|
||||
foreach ($f in (Get-ChildItem $Overlay -Recurse -Filter '*.java' -File)) {
|
||||
$rel = $f.FullName.Substring($Overlay.Length).TrimStart('\')
|
||||
$target = Join-Path $Src $rel
|
||||
$dir = Split-Path -Parent $target
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
||||
Copy-Item -LiteralPath $f.FullName -Destination $target -Force
|
||||
$copied++
|
||||
}
|
||||
Say " overlay: $copied source file(s) from src-overlay"
|
||||
}
|
||||
|
||||
# 6. re-apply the hand fixes
|
||||
Say 'applying hand fixes'
|
||||
& python (Join-Path $Client 'tools\apply_patches.py') $Src (Join-Path $Fork 'patches\decompiler-fixes.txt')
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host ' WARNING: some patches did not apply' -ForegroundColor Yellow }
|
||||
|
||||
# 6b. the systematic rewrites, which would be hundreds of patch entries one by one
|
||||
Say 'applying systematic rules'
|
||||
& python (Join-Path $Client 'tools\fix_decompiler_rules.py') $Src
|
||||
|
||||
# 7. compile, let the automated fixers converge, compile again.
|
||||
#
|
||||
# The fixers need javac's diagnostics as their input, and fixing one error can
|
||||
# reveal the next, so this runs to a fixed point rather than once. It stops as
|
||||
# soon as a round fails to reduce the count, which is the signal that the
|
||||
# remaining errors need a human.
|
||||
$argf = Join-Path $Temp 'fork-build.txt'
|
||||
$errf = Join-Path $Temp 'fork-build-errors.txt'
|
||||
|
||||
$good = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '*.jar' -File)) {
|
||||
if ($j.Length -le 100) { continue }
|
||||
$ok = $false
|
||||
try { $z = [System.IO.Compression.ZipFile]::OpenRead($j.FullName); $z.Dispose(); $ok = $true } catch { $ok = $false }
|
||||
if ($ok) { $good.Add(($j.FullName -replace '\\', '/')) }
|
||||
}
|
||||
# The fork calls into the mod loader, so the loader has to be visible to javac
|
||||
# exactly as it is at runtime.
|
||||
$Loader = Join-Path $Client 'dist\rsx-loader.jar'
|
||||
$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';'
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
foreach ($a in @('-nowarn', '-proc:none', '-encoding', 'UTF-8', '--release', '17', '-d', ($Out -replace '\\', '/'))) { [void]$sb.AppendLine($a) }
|
||||
[void]$sb.AppendLine('-cp'); [void]$sb.AppendLine('"' + $cp + '"')
|
||||
Get-ChildItem $Src -Recurse -Filter '*.java' | ForEach-Object { [void]$sb.AppendLine(($_.FullName -replace '\\', '/')) }
|
||||
Set-Content -LiteralPath $argf -Value $sb.ToString() -Encoding ASCII
|
||||
|
||||
$javac = (Get-Command javac).Source
|
||||
function Invoke-Compile {
|
||||
Remove-Item $Out -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Out | Out-Null
|
||||
& cmd /c "`"$javac`" @`"$argf`" -Xmaxerrs 10000 > `"$errf`" 2>&1"
|
||||
return (Get-Content -LiteralPath $errf | Select-String -Pattern ': error:').Count
|
||||
}
|
||||
|
||||
$count = Invoke-Compile
|
||||
Say "compiled, $count errors"
|
||||
$previous = [int]::MaxValue
|
||||
for ($round = 1; $round -le 8 -and $count -gt 0; $round++) {
|
||||
if ($count -ge $previous) { Say "no further progress, stopping"; break }
|
||||
$previous = $count
|
||||
Say "fix round $round"
|
||||
& python (Join-Path $Client 'tools\fix_stale_locals.py') $errf --apply | Out-Null
|
||||
& python (Join-Path $Client 'tools\fix_generic_casts.py') $errf --apply | Out-Null
|
||||
& python (Join-Path $Client 'tools\fix_decompiler_rules.py') $Src | Out-Null
|
||||
$count = Invoke-Compile
|
||||
Say " -> $count errors"
|
||||
}
|
||||
|
||||
$errors = Get-Content -LiteralPath $errf | Select-String -Pattern ': error:'
|
||||
$files = $errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } | Sort-Object -Unique
|
||||
Write-Host ''
|
||||
if ($errors.Count -eq 0) {
|
||||
Write-Host ' FORK COMPILES CLEAN' -ForegroundColor Green
|
||||
Write-Host " $((Get-ChildItem $Out -Recurse -Filter '*.class' -File).Count) classes"
|
||||
} else {
|
||||
Write-Host " $((Get-ChildItem $Out -Recurse -Filter '*.class' -File).Count) classes, $($errors.Count) errors in $($files.Count) files" -ForegroundColor Yellow
|
||||
$errors | ForEach-Object { if ($_.Line -match '^([A-Z]:.*?\.java):\d+:') { $matches[1] } } |
|
||||
Group-Object | Sort-Object Count -Descending | Select-Object -First 15 |
|
||||
ForEach-Object { Write-Host (" {0,4} {1}" -f $_.Count, ($_.Name.Replace(($Src + '\'), ''))) -ForegroundColor Gray }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package net.rustnotmc.client;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.ScaledResolution;
|
||||
import net.minecraft.client.renderer.GlStateManager;
|
||||
import net.rustnotmc.api.client.ClientHost;
|
||||
import net.rustnotmc.api.hud.HudLayout;
|
||||
import net.rustnotmc.loader.Log;
|
||||
import net.rustnotmc.loader.ModContainer;
|
||||
import net.rustnotmc.loader.ModLoader;
|
||||
|
||||
/**
|
||||
* Where the client and the mod loader meet.
|
||||
*
|
||||
* The loader is a library, not an agent. The game calls in at two points: once
|
||||
* during startup, and once per frame. Everything a mod does hangs off those two
|
||||
* calls, so the loader never has to know how a game loop is shaped.
|
||||
*
|
||||
* Every entry point is wrapped. A mod that throws must not be able to take the
|
||||
* client down with it, and a loader that cannot start must not stop the game
|
||||
* from being a game.
|
||||
*
|
||||
* This file lives in game-fork/src-overlay because src is regenerated from the
|
||||
* vanilla jar on every build and anything written there is lost.
|
||||
*/
|
||||
public final class RsxBootstrap {
|
||||
|
||||
private static ModLoader loader;
|
||||
private static RsxHudSurface surface;
|
||||
private static RsxClientServices client;
|
||||
private static boolean broken;
|
||||
private static boolean frameFailed;
|
||||
|
||||
private RsxBootstrap() {
|
||||
}
|
||||
|
||||
/** True once mods have been discovered and enabled. */
|
||||
public static boolean ready() {
|
||||
return loader != null && !broken;
|
||||
}
|
||||
|
||||
public static ModLoader loader() {
|
||||
return loader;
|
||||
}
|
||||
|
||||
public static int loadedMods() {
|
||||
return loader == null ? 0 : loader.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and enables mods. Called once, after the client has a display,
|
||||
* a font and a resource manager, so a mod may touch any of them.
|
||||
*/
|
||||
public static synchronized void start(Minecraft minecraft) {
|
||||
if (loader != null || broken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// the client API goes up before any mod runs, so onConstruct can use
|
||||
// it: a mod registers commands and reads the screen there
|
||||
client = new RsxClientServices(minecraft);
|
||||
ClientHost.install(client);
|
||||
|
||||
File directory = new File(minecraft.gameDir, "mods");
|
||||
loader = new ModLoader(directory);
|
||||
|
||||
int found = loader.discover();
|
||||
Log.info("RustNotMC: scanning " + directory + ", " + found + " mod jar(s)");
|
||||
|
||||
if (!loader.resolve()) {
|
||||
reportProblems();
|
||||
broken = true;
|
||||
loader = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loader.loadAll();
|
||||
surface = new RsxHudSurface(minecraft);
|
||||
reportProblems();
|
||||
|
||||
if (loader.count() == 0) {
|
||||
Log.info("RustNotMC: no mods loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
StringBuilder line = new StringBuilder("RustNotMC: loaded ");
|
||||
List<ModContainer> mods = loader.mods();
|
||||
for (int i = 0; i < mods.size(); i++) {
|
||||
if (i > 0) {
|
||||
line.append(", ");
|
||||
}
|
||||
ModContainer mod = mods.get(i);
|
||||
line.append(mod.id()).append(' ').append(mod.version());
|
||||
}
|
||||
Log.info(line.toString());
|
||||
} catch (Throwable throwable) {
|
||||
broken = true;
|
||||
loader = null;
|
||||
Log.error("RustNotMC: mod loader failed to start", throwable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers a typed chat line to the mod commands.
|
||||
*
|
||||
* Called from the point where a line leaves the client. Returns true when a
|
||||
* CLIENT scoped command carried it out and the line must not be sent on.
|
||||
* SERVER and BOTH commands return false on purpose: the server is the one
|
||||
* that has to see them and decide.
|
||||
*/
|
||||
public static boolean handleChatCommand(String line) {
|
||||
if (client == null || !ClientHost.present()) {
|
||||
return false;
|
||||
}
|
||||
return client.dispatchCommand(line, localName());
|
||||
}
|
||||
|
||||
private static String localName() {
|
||||
Minecraft mc = Minecraft.getMinecraft();
|
||||
if (mc != null && mc.getSession() != null) {
|
||||
return mc.getSession().getUsername();
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
|
||||
private static void reportProblems() {
|
||||
List<String> problems = loader.problems();
|
||||
for (int i = 0; i < problems.size(); i++) {
|
||||
Log.error("RustNotMC: " + problems.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs scheduled mod work and draws registered overlays.
|
||||
*
|
||||
* Called once per frame with the main framebuffer bound and the game's own
|
||||
* matrices already unwound, so the overlay sets up its own projection and
|
||||
* leaves the state it did not set alone.
|
||||
*/
|
||||
public static void renderFrame(Minecraft minecraft) {
|
||||
if (loader == null || surface == null || !surface.available()) {
|
||||
return;
|
||||
}
|
||||
if (frameFailed) {
|
||||
return;
|
||||
}
|
||||
if (client != null) {
|
||||
client.advanceFrame();
|
||||
}
|
||||
try {
|
||||
loader.tick();
|
||||
if (client != null) {
|
||||
client.advanceTick();
|
||||
}
|
||||
|
||||
ScaledResolution scaled = new ScaledResolution(minecraft);
|
||||
int width = scaled.getScaledWidth();
|
||||
int height = scaled.getScaledHeight();
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the overlay draws in flat screen space, so depth would fight it
|
||||
// and the blend state has to be ours for the duration
|
||||
GlStateManager.disableDepth();
|
||||
|
||||
GlStateManager.pushMatrix();
|
||||
GlStateManager.matrixMode(5889);
|
||||
GlStateManager.loadIdentity();
|
||||
GlStateManager.ortho(0.0D, width, height, 0.0D, 1000.0D, 3000.0D);
|
||||
GlStateManager.matrixMode(5888);
|
||||
GlStateManager.loadIdentity();
|
||||
GlStateManager.translate(0.0F, 0.0F, -2000.0F);
|
||||
|
||||
GlStateManager.enableBlend();
|
||||
GlStateManager.tryBlendFuncSeparate(
|
||||
GlStateManager.SourceFactor.SRC_ALPHA,
|
||||
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA,
|
||||
GlStateManager.SourceFactor.ONE,
|
||||
GlStateManager.DestFactor.ZERO);
|
||||
|
||||
try {
|
||||
HudLayout.render(surface);
|
||||
} finally {
|
||||
GlStateManager.disableBlend();
|
||||
GlStateManager.enableDepth();
|
||||
GlStateManager.popMatrix();
|
||||
}
|
||||
} catch (Throwable throwable) {
|
||||
// one bad frame is a bug worth seeing, but it must not become a
|
||||
// per-frame stack trace or a dead client
|
||||
frameFailed = true;
|
||||
Log.error("RustNotMC: overlay rendering failed, disabling overlays", throwable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package net.rustnotmc.client;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiChat;
|
||||
import net.minecraft.client.gui.GuiIngameMenu;
|
||||
import net.minecraft.client.gui.GuiMainMenu;
|
||||
import net.minecraft.client.gui.GuiOptions;
|
||||
import net.minecraft.client.gui.GuiScreen;
|
||||
import net.minecraft.client.gui.GuiWorldSelection;
|
||||
import net.minecraft.client.gui.achievement.GuiStats;
|
||||
import net.minecraft.client.gui.advancements.GuiScreenAdvancements;
|
||||
import net.minecraft.client.gui.inventory.GuiContainer;
|
||||
import net.minecraft.client.settings.KeyBinding;
|
||||
import net.rustnotmc.api.client.ClientServices;
|
||||
import net.rustnotmc.api.client.Commands;
|
||||
import net.rustnotmc.api.client.Input;
|
||||
import net.rustnotmc.api.client.Screen;
|
||||
import net.rustnotmc.api.client.ScreenKind;
|
||||
import org.lwjgl.input.Mouse;
|
||||
|
||||
/**
|
||||
* The client's implementation of the mod API.
|
||||
*
|
||||
* Everything here reads presentation state the client already has: what screen
|
||||
* is open, what the window measures, which key is held. Nothing reaches into
|
||||
* the world, a player or a packet, because the server owns those and a client
|
||||
* mod is not allowed to decide them.
|
||||
*
|
||||
* The values are recomputed per call rather than cached. A mod may ask once a
|
||||
* frame, and a stale answer would be worse than a cheap one.
|
||||
*
|
||||
* Lives in game-fork/src-overlay; src is regenerated on every build.
|
||||
*/
|
||||
public final class RsxClientServices implements ClientServices {
|
||||
|
||||
private final Minecraft minecraft;
|
||||
private final RsxScreen screen = new RsxScreen();
|
||||
private final RsxInput input = new RsxInput();
|
||||
private final RsxCommands commands = new RsxCommands();
|
||||
|
||||
private long tick;
|
||||
private long frame;
|
||||
|
||||
public RsxClientServices(Minecraft minecraft) {
|
||||
this.minecraft = minecraft;
|
||||
}
|
||||
|
||||
/** Called once per frame by the bootstrap, before anything is asked. */
|
||||
public void advanceFrame() {
|
||||
this.frame++;
|
||||
}
|
||||
|
||||
/** Called once per tick by the bootstrap. */
|
||||
public void advanceTick() {
|
||||
this.tick++;
|
||||
}
|
||||
|
||||
boolean dispatchCommand(String line, String sender) {
|
||||
return this.commands.dispatch(line, sender);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Screen screen() {
|
||||
return this.screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Input input() {
|
||||
return this.input;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Commands commands() {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long tick() {
|
||||
return this.tick;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long frame() {
|
||||
return this.frame;
|
||||
}
|
||||
|
||||
private final class RsxScreen implements Screen {
|
||||
|
||||
@Override
|
||||
public ScreenKind kind() {
|
||||
GuiScreen current = minecraft.currentScreen;
|
||||
if (current == null) {
|
||||
return minecraft.world == null ? ScreenKind.UNKNOWN : ScreenKind.IN_GAME;
|
||||
}
|
||||
if (current instanceof GuiChat) {
|
||||
return ScreenKind.CHAT;
|
||||
}
|
||||
if (current instanceof GuiIngameMenu) {
|
||||
return ScreenKind.PAUSED;
|
||||
}
|
||||
if (current instanceof GuiMainMenu) {
|
||||
return ScreenKind.MAIN_MENU;
|
||||
}
|
||||
if (current instanceof GuiWorldSelection) {
|
||||
return ScreenKind.WORLD_SELECTION;
|
||||
}
|
||||
if (current instanceof GuiOptions) {
|
||||
return ScreenKind.OPTIONS;
|
||||
}
|
||||
if (current instanceof GuiContainer) {
|
||||
return ScreenKind.INVENTORY;
|
||||
}
|
||||
if (current instanceof GuiScreenAdvancements || current instanceof GuiStats) {
|
||||
return ScreenKind.ADVANCEMENTS;
|
||||
}
|
||||
// a screen this build has no name for, over a loaded world or not
|
||||
return minecraft.world == null ? ScreenKind.UNKNOWN : ScreenKind.PAUSED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int width() {
|
||||
return minecraft.displayWidth / Math.max(1, scaleFactor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int height() {
|
||||
return minecraft.displayHeight / Math.max(1, scaleFactor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public float scale() {
|
||||
return (float) scaleFactor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int windowWidth() {
|
||||
return minecraft.displayWidth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int windowHeight() {
|
||||
return minecraft.displayHeight;
|
||||
}
|
||||
|
||||
private int scaleFactor() {
|
||||
int gui = minecraft.gameSettings == null ? 2 : minecraft.gameSettings.guiScale;
|
||||
if (gui <= 0) {
|
||||
// auto: the client picks the largest scale that keeps the
|
||||
// interface at least this wide
|
||||
int width = minecraft.displayWidth;
|
||||
gui = 1;
|
||||
while (width / (gui + 1) >= 320 && gui < 4) {
|
||||
gui++;
|
||||
}
|
||||
}
|
||||
return gui;
|
||||
}
|
||||
}
|
||||
|
||||
private final class RsxInput implements Input {
|
||||
|
||||
@Override
|
||||
public boolean keyDown(String keyId) {
|
||||
if (keyId == null || minecraft.gameSettings == null) {
|
||||
return false;
|
||||
}
|
||||
KeyBinding[] bindings = minecraft.gameSettings.keyBindings;
|
||||
if (bindings == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < bindings.length; i++) {
|
||||
if (keyId.equals(bindings[i].getKeyDescription()) && bindings[i].isKeyDown()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean anyKeyDown() {
|
||||
KeyBinding[] bindings = minecraft.gameSettings == null
|
||||
? null : minecraft.gameSettings.keyBindings;
|
||||
if (bindings == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < bindings.length; i++) {
|
||||
if (bindings[i].isKeyDown()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean focused() {
|
||||
return minecraft.inGameHasFocus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int mouseX() {
|
||||
return scaled(Mouse.getX());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int mouseY() {
|
||||
return scaled(Mouse.getY());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int mouseButtons() {
|
||||
int buttons = 0;
|
||||
if (Mouse.isButtonDown(0)) {
|
||||
buttons |= 1;
|
||||
}
|
||||
if (Mouse.isButtonDown(1)) {
|
||||
buttons |= 2;
|
||||
}
|
||||
if (Mouse.isButtonDown(2)) {
|
||||
buttons |= 4;
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDown(int button) {
|
||||
return button >= 0 && button <= 2 && Mouse.isButtonDown(button);
|
||||
}
|
||||
|
||||
private int scaled(int windowCoord) {
|
||||
return windowCoord / Math.max(1, screen.scaleFactor());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package net.rustnotmc.client;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import net.rustnotmc.api.client.ClientCommand;
|
||||
import net.rustnotmc.api.client.CommandArgs;
|
||||
import net.rustnotmc.api.client.CommandScope;
|
||||
import net.rustnotmc.api.client.Commands;
|
||||
import net.rustnotmc.loader.Log;
|
||||
import net.rustnotmc.loader.ModLoader;
|
||||
|
||||
/**
|
||||
* The mod command table.
|
||||
*
|
||||
* Only a CLIENT scoped command is carried out here. SERVER and BOTH are not
|
||||
* consumed by the client at all: the line is left for the server to take, which
|
||||
* is the whole point of the scope. The client never decides an effect the server
|
||||
* owns.
|
||||
*
|
||||
* The table is shared by every mod, so a name can only be claimed once. A second
|
||||
* mod asking for a taken name is refused loudly rather than silently shadowing
|
||||
* the first, which would be miserable to debug.
|
||||
*
|
||||
* Lives in game-fork/src-overlay; src is regenerated on every build.
|
||||
*/
|
||||
final class RsxCommands implements Commands {
|
||||
|
||||
private static final class Entry {
|
||||
final String owner;
|
||||
final ClientCommand handler;
|
||||
final CommandScope scope;
|
||||
|
||||
Entry(String owner, ClientCommand handler, CommandScope scope) {
|
||||
this.owner = owner;
|
||||
this.handler = handler;
|
||||
this.scope = scope;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, Entry> entries = new LinkedHashMap<String, Entry>();
|
||||
|
||||
@Override
|
||||
public boolean register(String name, ClientCommand handler, CommandScope scope) {
|
||||
if (name == null || handler == null || scope == null) {
|
||||
return false;
|
||||
}
|
||||
String key = normalise(name);
|
||||
if (key.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
String owner = registering();
|
||||
Entry clash = this.entries.get(key);
|
||||
if (clash != null) {
|
||||
if (clash.owner.equals(owner) && clash.handler == handler) {
|
||||
return true;
|
||||
}
|
||||
Log.warn("command /" + key + " is already registered by " + clash.owner
|
||||
+ "; the second registration was refused");
|
||||
return false;
|
||||
}
|
||||
this.entries.put(key, new Entry(owner, handler, scope));
|
||||
Log.info("registered command /" + key + " scope=" + scope.name().toLowerCase());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean register(String name, ClientCommand handler) {
|
||||
return register(name, handler, CommandScope.CLIENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRegistered(String name) {
|
||||
return name != null && this.entries.containsKey(normalise(name));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandScope scopeOf(String name) {
|
||||
Entry e = name == null ? null : this.entries.get(normalise(name));
|
||||
return e == null ? null : e.scope;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int count() {
|
||||
return this.entries.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers a typed line to the mod commands.
|
||||
*
|
||||
* @return true when the client carried it out and the line must not be sent
|
||||
* on. False means the line is not a client command, which is the
|
||||
* answer for SERVER and BOTH: the server has to see those.
|
||||
*/
|
||||
boolean dispatch(String line, String sender) {
|
||||
if (line == null || !line.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
String body = line.substring(1).trim();
|
||||
int space = body.indexOf(' ');
|
||||
String name = space < 0 ? body : body.substring(0, space);
|
||||
Entry entry = this.entries.get(normalise(name));
|
||||
if (entry == null || entry.scope != CommandScope.CLIENT) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
entry.handler.run(CommandArgs.parse(body, sender));
|
||||
} catch (Throwable t) {
|
||||
Log.error("command /" + name + " failed", t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String normalise(String name) {
|
||||
String v = name.trim();
|
||||
if (v.startsWith("/")) {
|
||||
v = v.substring(1);
|
||||
}
|
||||
return v.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is registering right now.
|
||||
*
|
||||
* The loader knows this because it is the one calling onConstruct, and the
|
||||
* game can read it without the loader ever depending on the game.
|
||||
*/
|
||||
private static String registering() {
|
||||
String who = ModLoader.REGISTERING.get();
|
||||
return who == null ? "unknown" : who;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package net.rustnotmc.client;
|
||||
|
||||
import net.rustnotmc.loader.Log;
|
||||
|
||||
/**
|
||||
* Temporary diagnostics, added to work out why jumping and flight misbehave.
|
||||
*
|
||||
* Static analysis kept producing confident and wrong answers about this path, so
|
||||
* the state is measured instead. Delete this file and its patch entry once the
|
||||
* movement bugs are closed.
|
||||
*/
|
||||
public final class RsxDebug {
|
||||
|
||||
private static int ticks;
|
||||
|
||||
private RsxDebug() {
|
||||
}
|
||||
|
||||
/** One-off trace for the empty sound registry. */
|
||||
public static void sound(String message) {
|
||||
Log.info("DBG snd " + message);
|
||||
}
|
||||
|
||||
/** Called from EntityPlayerSP.onLivingUpdate, once a second. */
|
||||
public static void movement(Object self) {
|
||||
if (++ticks % 20 != 0) {
|
||||
return;
|
||||
}
|
||||
Log.info("DBG sp onGround=" + read(self, "onGround")
|
||||
+ " inJump=" + read(self, "movementInput")
|
||||
+ " isJumping=" + read(self, "isJumping")
|
||||
+ " riding=" + read(self, "isRiding")
|
||||
+ " srvWorld=" + read(self, "isServerWorld")
|
||||
+ " allowFly=" + read(self, "allowFlying")
|
||||
+ " flying=" + read(self, "isFlying")
|
||||
+ " mY=" + read(self, "motionY")
|
||||
+ " y=" + read(self, "posY"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a field by name.
|
||||
*
|
||||
* Deliberately reflective and deliberately forgiving: a diagnostic that
|
||||
* throws when a field is renamed is worse than one that prints null.
|
||||
*/
|
||||
private static String read(Object target, String field) {
|
||||
if (target == null) {
|
||||
return "?";
|
||||
}
|
||||
try {
|
||||
java.lang.reflect.Field f = findField(target.getClass(), field);
|
||||
if (f == null) {
|
||||
return "?";
|
||||
}
|
||||
f.setAccessible(true);
|
||||
return String.valueOf(f.get(target));
|
||||
} catch (Throwable t) {
|
||||
return "!";
|
||||
}
|
||||
}
|
||||
|
||||
private static java.lang.reflect.Field findField(Class<?> type, String name) {
|
||||
while (type != null) {
|
||||
try {
|
||||
return type.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException e) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package net.rustnotmc.client;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.ScaledResolution;
|
||||
import net.minecraft.client.renderer.BufferBuilder;
|
||||
import net.minecraft.client.renderer.GlStateManager;
|
||||
import net.minecraft.client.renderer.Tessellator;
|
||||
import net.minecraft.client.renderer.vertex.DefaultVertexFormats;
|
||||
import net.rustnotmc.api.hud.HudSurface;
|
||||
|
||||
/**
|
||||
* The game's implementation of the mod HUD surface.
|
||||
*
|
||||
* A mod draws through this interface and never touches a game type, which is
|
||||
* what lets the layout and the overlay code be tested with the game closed. The
|
||||
* reverse cost is paid once, here: everything a mod needs from Minecraft is
|
||||
* resolved in this class.
|
||||
*
|
||||
* Coordinates are the scaled interface ones, so an element lines up with the
|
||||
* health bar and the chat without the mod knowing about GUI scale.
|
||||
*
|
||||
* Lives in game-fork/src-overlay; src is regenerated on every build.
|
||||
*/
|
||||
public final class RsxHudSurface implements HudSurface {
|
||||
|
||||
private final Minecraft minecraft;
|
||||
|
||||
/**
|
||||
* Rebuilt only when the window or the scale factor actually changes. A new
|
||||
* ScaledResolution per frame would be one short-lived object sixty times a
|
||||
* second for a value that is almost never different.
|
||||
*/
|
||||
private ScaledResolution resolution;
|
||||
private int cachedWidth = -1;
|
||||
private int cachedHeight = -1;
|
||||
private int cachedScale = -1;
|
||||
|
||||
public RsxHudSurface(Minecraft minecraft) {
|
||||
this.minecraft = minecraft;
|
||||
}
|
||||
|
||||
private ScaledResolution resolution() {
|
||||
int width = this.minecraft.displayWidth;
|
||||
int height = this.minecraft.displayHeight;
|
||||
int scale = this.minecraft.gameSettings.guiScale;
|
||||
if (this.resolution == null
|
||||
|| width != this.cachedWidth
|
||||
|| height != this.cachedHeight
|
||||
|| scale != this.cachedScale) {
|
||||
this.resolution = new ScaledResolution(this.minecraft);
|
||||
this.cachedWidth = width;
|
||||
this.cachedHeight = height;
|
||||
this.cachedScale = scale;
|
||||
}
|
||||
return this.resolution;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean available() {
|
||||
return this.minecraft != null && this.minecraft.fontRenderer != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int width() {
|
||||
return this.available() ? resolution().getScaledWidth() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int height() {
|
||||
return this.available() ? resolution().getScaledHeight() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float scale() {
|
||||
return (float) resolution().getScaleFactor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rect(float x, float y, float w, float h, int rgb, float alpha) {
|
||||
if (w <= 0.0F || h <= 0.0F) {
|
||||
return;
|
||||
}
|
||||
int a = clampAlpha(alpha);
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
|
||||
// the vanilla debug overlay draws coloured quads the same way; this
|
||||
// GlStateManager has no isTextureEnabled to ask, so the state is set
|
||||
// explicitly the way the game itself does it
|
||||
GlStateManager.disableTexture2D();
|
||||
|
||||
Tessellator tessellator = Tessellator.getInstance();
|
||||
BufferBuilder buffer = tessellator.getBuffer();
|
||||
buffer.begin(7, DefaultVertexFormats.POSITION_COLOR);
|
||||
buffer.pos(x, y + h, 0.0D).color(r, g, b, a).endVertex();
|
||||
buffer.pos(x + w, y + h, 0.0D).color(r, g, b, a).endVertex();
|
||||
buffer.pos(x + w, y, 0.0D).color(r, g, b, a).endVertex();
|
||||
buffer.pos(x, y, 0.0D).color(r, g, b, a).endVertex();
|
||||
tessellator.draw();
|
||||
|
||||
GlStateManager.enableTexture2D();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void text(float x, float y, String value, int rgb, float alpha) {
|
||||
if (value == null || value.isEmpty() || !this.available()) {
|
||||
return;
|
||||
}
|
||||
GlStateManager.enableTexture2D();
|
||||
this.minecraft.fontRenderer.drawStringWithShadow(
|
||||
value, x, y, (rgb & 0x00FFFFFF) | (clampAlpha(alpha) << 24));
|
||||
}
|
||||
|
||||
@Override
|
||||
public float textWidth(String value) {
|
||||
if (value == null || value.isEmpty() || !this.available()) {
|
||||
return 0.0F;
|
||||
}
|
||||
return (float) this.minecraft.fontRenderer.getStringWidth(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int textHeight() {
|
||||
return this.available() ? this.minecraft.fontRenderer.FONT_HEIGHT : 0;
|
||||
}
|
||||
|
||||
private static int clampAlpha(float alpha) {
|
||||
if (alpha < 0.0F) {
|
||||
return 0;
|
||||
}
|
||||
if (alpha > 1.0F) {
|
||||
return 255;
|
||||
}
|
||||
return (int) (alpha * 255.0F + 0.5F);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user