diff --git a/.github/workflows/build-jar.yml b/.github/workflows/build-jar.yml index 5d221f4..13cf37e 100644 --- a/.github/workflows/build-jar.yml +++ b/.github/workflows/build-jar.yml @@ -22,6 +22,13 @@ jobs: java-version: "17" cache: maven + # For tag builds, stamp the project version from the tag (v1.7 -> 1.7) so the jar's + # Implementation-Version matches the release. Without this the manifest keeps the + # hardcoded pom version and the updater re-downloads the same release forever. + - name: Set release version from tag + if: startsWith(github.ref, 'refs/tags/v') + run: mvn --batch-mode versions:set -DnewVersion="${GITHUB_REF_NAME#v}" -DgenerateBackupPoms=false + - name: Build run: mvn --batch-mode clean package diff --git a/.gitignore b/.gitignore index fc8bc18..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -target/Progressive-Java-Client.jar +/target diff --git a/build.ps1 b/build.ps1 index ab418c6..c6f8523 100644 --- a/build.ps1 +++ b/build.ps1 @@ -18,7 +18,7 @@ if (Test-Path src/main/resources) { Get-ChildItem -Recurse src/main/java -Filter *.java | ForEach-Object FullName | Set-Content sources.txt $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" -javac -J-Xmx512m --release 17 -encoding UTF-8 -cp "lib/*" -d target/classes '@sources.txt' +javac -J-Xmx1g --release 17 -encoding UTF-8 -cp "lib/*" -d target/classes '@sources.txt' $javacExitCode = $LASTEXITCODE $ErrorActionPreference = $previousErrorActionPreference Remove-Item sources.txt -Force @@ -44,11 +44,30 @@ Remove-Item target/classes/META-INF/*.SF -Force -ErrorAction SilentlyContinue Remove-Item target/classes/META-INF/*.DSA -Force -ErrorAction SilentlyContinue Remove-Item target/classes/META-INF/*.RSA -Force -ErrorAction SilentlyContinue +$clientVersion = if ($env:CLIENT_VERSION) { $env:CLIENT_VERSION.TrimStart("v") } else { "1.7" } +@" +{ + "version": "$clientVersion", + "web_host": "localhost", + "web_port": 80, + "game_port": 43594 +} +"@ | Set-Content -Encoding UTF8 target/config.json @" Manifest-Version: 1.0 +Implementation-Version: $clientVersion +Build-Time: $((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")) "@ | Set-Content -Encoding ascii target/manifest.mf +# Build the updater jar first, then fold it into the client classes so it ships +# *inside* the client jar. At runtime the client extracts it back beside itself. +jar --create --file target/Progressive-Java-Updater.jar --main-class com.gradwahl.rs254.update.UpdateHelper -C target/classes com/gradwahl/rs254/update +if ($LASTEXITCODE -ne 0) { + throw "updater jar failed with exit code $LASTEXITCODE" +} +Copy-Item target/Progressive-Java-Updater.jar target/classes/Progressive-Java-Updater.jar -Force + jar --create --file target/Progressive-Java-Client.jar --main-class com.gradwahl.rs254.Main --manifest target/manifest.mf -C target/classes . if ($LASTEXITCODE -ne 0) { throw "jar failed with exit code $LASTEXITCODE. Close any running client and rebuild." @@ -56,6 +75,7 @@ if ($LASTEXITCODE -ne 0) { Remove-Item target/manifest.mf Write-Host "Build complete: target/Progressive-Java-Client.jar" +Write-Host "Build complete: target/Progressive-Java-Updater.jar" # Wrap the JAR in a single .exe with the custom icon using Launch4j. $launch4jc = "C:\Program Files (x86)\Launch4j\launch4jc.exe" @@ -71,12 +91,13 @@ if (Test-Path $launch4jc) { gui $jarAbsPath $exeAbsPath + . Progressive Java Client $icoAbsPath 17 - -Dsun.java2d.noddraw=true --enable-native-access=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED + -Xmx1g -Dsun.java2d.noddraw=true -Drs254.logDir=logs -XX:ErrorFile=logs\jvm_crash.log --enable-native-access=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED 10 0 highmem members 32 diff --git a/build.sh b/build.sh index a842b16..b69fd36 100644 --- a/build.sh +++ b/build.sh @@ -23,7 +23,7 @@ fi find src/main/java -name "*.java" > sources.txt -javac -J-Xmx512m --release 17 -encoding UTF-8 -cp "lib/*" -d target/classes @sources.txt +javac -J-Xmx1g --release 17 -encoding UTF-8 -cp "lib/*" -d target/classes @sources.txt rm sources.txt # Fold runtime dependencies and LWJGL natives into the artifact so the JAR can @@ -34,7 +34,26 @@ done rm -f target/classes/META-INF/MANIFEST.MF rm -f target/classes/META-INF/*.SF target/classes/META-INF/*.DSA target/classes/META-INF/*.RSA -printf 'Manifest-Version: 1.0\n\n' > target/manifest.mf +BUILD_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +CLIENT_VERSION="${CLIENT_VERSION:-1.7}" +CLIENT_VERSION="${CLIENT_VERSION#v}" +cat > target/config.json < target/manifest.mf + +# Build the updater jar first, then fold it into the client classes so it ships +# *inside* the client jar. At runtime the client extracts it back beside itself. +jar --create --file target/Progressive-Java-Updater.jar \ + --main-class com.gradwahl.rs254.update.UpdateHelper \ + -C target/classes com/gradwahl/rs254/update + +cp target/Progressive-Java-Updater.jar target/classes/Progressive-Java-Updater.jar jar --create --file target/Progressive-Java-Client.jar \ --main-class com.gradwahl.rs254.Main \ @@ -44,4 +63,5 @@ jar --create --file target/Progressive-Java-Client.jar \ rm target/manifest.mf echo "Build complete: target/Progressive-Java-Client.jar" +echo "Build complete: target/Progressive-Java-Updater.jar" echo "Run with: ./run.sh" diff --git a/pom.xml b/pom.xml index 4be0891..0e78aac 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.gradwahl Progressive-Java-Client - 0.1.0 + 1.7 Progressive Java Client @@ -172,6 +172,10 @@ com.gradwahl.rs254.Main + + ${project.version} + ${maven.build.timestamp} + diff --git a/run.bat b/run.bat index 8a2f3c0..df48222 100644 --- a/run.bat +++ b/run.bat @@ -4,11 +4,12 @@ cd /d "%~dp0" set "SCRIPT_DIR=%~dp0" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" if not exist target\Progressive-Java-Client.jar call build.bat +if not exist target\Progressive-Java-Updater.jar call build.bat if not exist "%SCRIPT_DIR%\logs" mkdir "%SCRIPT_DIR%\logs" echo Starting RS2 client (HTTP :80, game :43594)... -java -Dsun.java2d.noddraw=true -Drs254.logDir="%SCRIPT_DIR%\logs" --enable-native-access=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED -XX:ErrorFile="%SCRIPT_DIR%\logs\jvm_crash_%%p.log" -jar target\Progressive-Java-Client.jar 10 0 highmem members 32 +java -Xmx1g -Dsun.java2d.noddraw=true -Drs254.logDir="%SCRIPT_DIR%\logs" --enable-native-access=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.reflect=ALL-UNNAMED -XX:ErrorFile="%SCRIPT_DIR%\logs\jvm_crash_%%p.log" -jar target\Progressive-Java-Client.jar 10 0 highmem members 32 if %ERRORLEVEL% neq 0 ( echo. diff --git a/run.sh b/run.sh index 667f072..c246cd9 100644 --- a/run.sh +++ b/run.sh @@ -58,7 +58,7 @@ if [ "$MISSING" -eq 1 ]; then REBUILD=1 fi -if [ ! -f target/Progressive-Java-Client.jar ] || [ "$REBUILD" -eq 1 ]; then +if [ ! -f target/Progressive-Java-Client.jar ] || [ ! -f target/Progressive-Java-Updater.jar ] || [ "$REBUILD" -eq 1 ]; then bash build.sh fi @@ -66,6 +66,7 @@ mkdir -p "$SCRIPT_DIR/logs" echo "Starting RS2 client (HTTP :80, game :43594)..." java \ + -Xmx1g \ -Drs254.logDir="$SCRIPT_DIR/logs" \ --enable-native-access=ALL-UNNAMED \ --add-opens java.base/java.lang=ALL-UNNAMED \ diff --git a/src/main/java/com/gradwahl/rs254/ClientConfig.java b/src/main/java/com/gradwahl/rs254/ClientConfig.java index 9e5d131..6dc0d29 100644 --- a/src/main/java/com/gradwahl/rs254/ClientConfig.java +++ b/src/main/java/com/gradwahl/rs254/ClientConfig.java @@ -3,20 +3,25 @@ import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.jar.Attributes; +import java.util.jar.Manifest; import java.util.regex.Matcher; import java.util.regex.Pattern; -public record ClientConfig(String host, int httpPort, int gamePort, boolean secure, int revision, String cacheDir, String dbPath) { +public record ClientConfig(String host, int httpPort, int gamePort, boolean secure, int revision, + String cacheDir, String dbPath, String version) { private static final String CONFIG_FILE = "config.json"; public static ClientConfig load() { File configFile = resolveConfigFile(); boolean firstRun = !configFile.exists(); + String version = currentVersionLabel(); if (firstRun) { String defaultConfig = "{\n" + + " \"version\": \"" + escapeJson(version) + "\",\n" + " \"web_host\": \"localhost\",\n" + " \"web_port\": 80,\n" + " \"game_port\": 43594\n" + @@ -30,11 +35,33 @@ public static ClientConfig load() { } } else { System.out.println("[Config] Loaded config from: " + configFile.getAbsolutePath()); + updateVersionField(configFile, version); } return parseFile(configFile); } + public static String currentVersionLabel() { + try { + String version = ClientConfig.class.getPackage().getImplementationVersion(); + if (version != null && !version.isBlank()) { + return version; + } + var manifestUrl = ClientConfig.class.getResource("/META-INF/MANIFEST.MF"); + if (manifestUrl != null) { + try (InputStream in = manifestUrl.openStream()) { + Attributes attrs = new Manifest(in).getMainAttributes(); + version = attrs.getValue("Implementation-Version"); + if (version != null && !version.isBlank()) { + return version; + } + } + } + } catch (Exception ignored) { + } + return "dev"; + } + private static File resolveConfigFile() { // Place config next to the JAR, falling back to the working directory try { @@ -54,10 +81,12 @@ private static ClientConfig parseFile(File file) { int revision = 254; String cacheDir = "cache"; String dbPath = ""; + String version = currentVersionLabel(); if (file.exists()) { try { String json = Files.readString(file.toPath(), StandardCharsets.UTF_8); + version = readString(json, "version", version); host = readString(json, "web_host", host); httpPort = readInt(json, "web_port", httpPort); gamePort = readInt(json, "game_port", gamePort); @@ -76,7 +105,30 @@ private static ClientConfig parseFile(File file) { cacheDir = System.getProperty("rs254.cacheDir", cacheDir); dbPath = System.getProperty("rs254.dbPath", dbPath); - return new ClientConfig(host, httpPort, gamePort, secure, revision, cacheDir, dbPath); + return new ClientConfig(host, httpPort, gamePort, secure, revision, cacheDir, dbPath, version); + } + + private static void updateVersionField(File file, String version) { + try { + String json = Files.readString(file.toPath(), StandardCharsets.UTF_8); + String escaped = escapeJson(version); + String updated; + if (Pattern.compile("\"version\"\\s*:").matcher(json).find()) { + updated = json.replaceFirst("\"version\"\\s*:\\s*\"((?:[^\\\\\"]|\\\\.)*)\"", + "\"version\": \"" + Matcher.quoteReplacement(escaped) + "\""); + } else { + int objectStart = json.indexOf('{'); + if (objectStart < 0) return; + updated = json.substring(0, objectStart + 1) + + "\n \"version\": \"" + escaped + "\"," + + json.substring(objectStart + 1); + } + if (!updated.equals(json)) { + Files.writeString(file.toPath(), updated, StandardCharsets.UTF_8); + } + } catch (IOException e) { + System.err.println("[Config] Warning: could not update version in " + file + ": " + e.getMessage()); + } } private static String readString(String json, String key, String defaultValue) { @@ -96,6 +148,10 @@ private static int readInt(String json, String key, int defaultValue) { return defaultValue; } + private static String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + /** @deprecated Use {@link #load()} instead. */ @Deprecated public static ClientConfig fromSystemProperties() { diff --git a/src/main/java/com/gradwahl/rs254/ClientDebugger.java b/src/main/java/com/gradwahl/rs254/ClientDebugger.java index d2966b6..a4a5ad5 100644 --- a/src/main/java/com/gradwahl/rs254/ClientDebugger.java +++ b/src/main/java/com/gradwahl/rs254/ClientDebugger.java @@ -1,14 +1,16 @@ package com.gradwahl.rs254; +import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Map; import java.util.concurrent.atomic.AtomicLong; /** - * Lightweight session debugger. Writes timestamped events to debug.log. + * Lightweight session debugger. Writes timestamped events to logs/debug.log. * Call ClientDebugger.enable() once at startup to activate. * * Detects: @@ -33,10 +35,19 @@ public enum LogoutReason { private static volatile boolean enabled = false; private static PrintWriter out; + private static File logDir; // render flash detection private static final AtomicLong lastRenderNs = new AtomicLong(0); private static final long FLASH_THRESHOLD_MS = 50; + private static final long MINIMIZED_HANG_THRESHOLD_MS = 5_000L; + private static final AtomicLong lastLoopNs = new AtomicLong(System.nanoTime()); + private static final AtomicLong lastDrawNs = new AtomicLong(System.nanoTime()); + private static volatile boolean renderPaused; + private static volatile String renderPauseReason = "startup"; + private static volatile int lastLoopCycle; + private static volatile int lastDrawCycle; + private static volatile long lastThreadDumpAtMs; // state snapshot at logout time public static volatile int lastIdleCycles = 0; @@ -45,9 +56,12 @@ public enum LogoutReason { public static void enable() { if (enabled) return; try { - out = new PrintWriter(new FileWriter("debug.log", true), true); + logDir = resolveLogDir(); + logDir.mkdirs(); + out = new PrintWriter(new FileWriter(new File(logDir, "debug.log"), true), true); enabled = true; log("=== ClientDebugger enabled ==="); + startWatchdog(); } catch (IOException e) { System.err.println("[debug] Could not open debug.log: " + e.getMessage()); } @@ -132,10 +146,113 @@ public static void onRenderStart() { } } + public static void onLoopHeartbeat(int loopCycle) { + if (!enabled) return; + lastLoopCycle = loopCycle; + lastLoopNs.set(System.nanoTime()); + } + + public static void onDrawHeartbeat(int drawCycle) { + if (!enabled) return; + lastDrawCycle = drawCycle; + lastDrawNs.set(System.nanoTime()); + } + + public static void onRenderPauseState(boolean paused, String reason, int framebufferW, int framebufferH) { + if (!enabled) return; + if (renderPaused != paused || !reason.equals(renderPauseReason)) { + renderPaused = paused; + renderPauseReason = reason; + log("[WINDOW] renderPaused=" + paused + + " reason=" + reason + + " framebuffer=" + framebufferW + "x" + framebufferH + + " loopCycle=" + lastLoopCycle + + " drawCycle=" + lastDrawCycle); + } + } + // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- + private static void startWatchdog() { + Thread watchdog = new Thread(() -> { + while (enabled) { + try { + Thread.sleep(1000L); + checkForMinimizedHang(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } catch (Throwable t) { + System.err.println("[debug] Watchdog error: " + t); + } + } + }, "minimize-hang-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + } + + private static void checkForMinimizedHang() throws IOException { + if (!renderPaused) return; + long nowNs = System.nanoTime(); + long loopGapMs = (nowNs - lastLoopNs.get()) / 1_000_000L; + long drawGapMs = (nowNs - lastDrawNs.get()) / 1_000_000L; + if (loopGapMs < MINIMIZED_HANG_THRESHOLD_MS) { + return; + } + + long nowMs = System.currentTimeMillis(); + if (nowMs - lastThreadDumpAtMs < MINIMIZED_HANG_THRESHOLD_MS) { + return; + } + lastThreadDumpAtMs = nowMs; + log("[WATCHDOG] possible minimized hang" + + " reason=" + renderPauseReason + + " loopGapMs=" + loopGapMs + + " drawGapMs=" + drawGapMs + + " loopCycle=" + lastLoopCycle + + " drawCycle=" + lastDrawCycle); + writeThreadDump(loopGapMs, drawGapMs); + } + + private static void writeThreadDump(long loopGapMs, long drawGapMs) throws IOException { + File file = new File(logDir, "minimize_hang_threads_" + + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()) + + ".log"); + try (PrintWriter dump = new PrintWriter(new FileWriter(file), true)) { + dump.println("Minimized/render-paused hang snapshot at " + LocalDateTime.now()); + dump.println("reason=" + renderPauseReason); + dump.println("loopGapMs=" + loopGapMs + " drawGapMs=" + drawGapMs); + dump.println("loopCycle=" + lastLoopCycle + " drawCycle=" + lastDrawCycle); + dump.println(); + for (Map.Entry entry : Thread.getAllStackTraces().entrySet()) { + Thread thread = entry.getKey(); + dump.println("\"" + thread.getName() + "\" state=" + thread.getState() + + " daemon=" + thread.isDaemon() + + " priority=" + thread.getPriority()); + for (StackTraceElement frame : entry.getValue()) { + dump.println(" at " + frame); + } + dump.println(); + } + } + log("[WATCHDOG] wrote " + file.getAbsolutePath()); + } + + private static File resolveLogDir() { + String configuredLogDir = System.getProperty("rs254.logDir"); + if (configuredLogDir != null && !configuredLogDir.isBlank()) { + return new File(configuredLogDir); + } + try { + File jar = new File(ClientDebugger.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return jar.isFile() ? new File(jar.getParentFile(), "logs") : new File("logs"); + } catch (Exception ignored) { + return new File("logs"); + } + } + public static void log(String msg) { if (out == null) return; out.println(LocalDateTime.now().format(FMT) + " " + msg); diff --git a/src/main/java/com/gradwahl/rs254/Main.java b/src/main/java/com/gradwahl/rs254/Main.java index ee7800f..50d7196 100644 --- a/src/main/java/com/gradwahl/rs254/Main.java +++ b/src/main/java/com/gradwahl/rs254/Main.java @@ -5,6 +5,8 @@ import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -13,6 +15,10 @@ private Main() {} public static void main(String[] args) throws Exception { setupErrorLogging(); + applyEarlyGraphicsProperties(); + relaunchJarWithOneGbHeapIfNeeded(args); + com.gradwahl.rs254.update.ClientUpdater.ensureUpdaterExtracted(); + ClientDebugger.enable(); ClientConfig config = ClientConfig.load(); applyConfig(config); @@ -23,6 +29,41 @@ public static void main(String[] args) throws Exception { jagex2.client.Client.main(clientArgs); } + private static void relaunchJarWithOneGbHeapIfNeeded(String[] args) throws Exception { + if (Boolean.getBoolean("rs254.heapRelaunched") || Runtime.getRuntime().maxMemory() >= 900L * 1024L * 1024L) { + return; + } + File current = new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (!current.isFile() || !current.getName().toLowerCase().endsWith(".jar")) { + return; + } + + String javaBin = new File(System.getProperty("java.home"), "bin" + File.separator + "javaw").getPath(); + if (!new File(javaBin + (isWindows() ? ".exe" : "")).exists()) { + javaBin = new File(System.getProperty("java.home"), "bin" + File.separator + "java").getPath(); + } + + List command = new ArrayList<>(); + command.add(javaBin); + command.add("-Xmx1g"); + command.add("-Drs254.heapRelaunched=true"); + command.add("-Dsun.java2d.noddraw=true"); + command.add("--enable-native-access=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang.reflect=ALL-UNNAMED"); + command.add("-jar"); + command.add(current.getPath()); + for (String arg : args) { + command.add(arg); + } + new ProcessBuilder(command) + .directory(current.getParentFile()) + .start(); + System.exit(0); + } + private static void applyConfig(ClientConfig config) { // Publish loaded values as system properties so downstream code can read them System.setProperty("rs254.host", config.host()); @@ -33,7 +74,17 @@ private static void applyConfig(ClientConfig config) { } } + private static void applyEarlyGraphicsProperties() { + // Must be set before any AWT/Java2D classes create a Windows DirectDraw pipeline. + System.setProperty("sun.java2d.noddraw", "true"); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + private static void setupErrorLogging() { + ErrorLogOutputStream.ensureLogDir(); System.setErr(new PrintStream(new ErrorLogOutputStream(System.err), true)); Thread.setDefaultUncaughtExceptionHandler((t, e) -> { System.err.println("\nCRASH on thread [" + t.getName() + "] at " + LocalDateTime.now()); @@ -54,6 +105,10 @@ private ErrorLogOutputStream(PrintStream console) { this.console = console; } + private static void ensureLogDir() { + resolveLogDir().mkdirs(); + } + @Override public synchronized void write(int value) throws IOException { console.write(value); @@ -94,7 +149,7 @@ private void openLogIfNeeded() { } } - private File resolveLogDir() { + private static File resolveLogDir() { File base; String configuredLogDir = System.getProperty("rs254.logDir"); if (configuredLogDir != null && !configuredLogDir.isBlank()) { diff --git a/src/main/java/com/gradwahl/rs254/gl/GLRenderer.java b/src/main/java/com/gradwahl/rs254/gl/GLRenderer.java index fc9230e..080a11b 100644 --- a/src/main/java/com/gradwahl/rs254/gl/GLRenderer.java +++ b/src/main/java/com/gradwahl/rs254/gl/GLRenderer.java @@ -5,6 +5,7 @@ import jagex2.graphics.Pix3D; import jagex2.graphics.PixMap; import jagex2.graphics.TriangleRenderer; +import com.gradwahl.rs254.ClientDebugger; import org.lwjgl.glfw.GLFWImage; import org.lwjgl.opengl.GL; import org.lwjgl.system.MemoryUtil; @@ -43,6 +44,7 @@ import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLEditorKit; +import com.gradwahl.rs254.ClientConfig; import com.gradwahl.rs254.discord.DiscordRichPresence; import static org.lwjgl.glfw.Callbacks.glfwFreeCallbacks; @@ -411,6 +413,11 @@ void main() { private long window; private int vao, vbo, prog; private int uScreen, uTex; + private boolean frameDrawable = true; + private boolean windowIconified; + private int framebufferW = 1; + private int framebufferH = 1; + private int restoreCooldownFrames; private final FloatBuffer buf = MemoryUtil.memAllocFloat(MAX_VERTS * FLOATS_PER_VERT); @@ -491,6 +498,7 @@ void main() { public static volatile boolean settingShiftExamineAnything; public static volatile boolean settingDiscordRichPresence; public static volatile boolean settingFps60Enabled; + private static volatile long settingFps60SuppressedUntilMs; private boolean sidebarOpen; private int sidebarTab; private boolean sidebarGpuEnabled = true; @@ -499,6 +507,7 @@ void main() { private boolean settingsFullscreen = false; private boolean settingsAfkDropdownOpen; private int settingsAfkIndex = SETTINGS_PREFS.getInt("afkIndex", 0); + private final String clientVersionText = "Version: " + ClientConfig.currentVersionLabel(); // XP session tracking — updated by Client when XP packets arrive public static final long[] xpSessionGains = new long[25]; @@ -675,8 +684,7 @@ public void init() { glUniform1i(uTex, 0); glClearColor(0f, 0f, 0f, 1f); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + restoreSceneGlState(); setupUIPass(); setupCallbacks(); @@ -746,6 +754,31 @@ public boolean shouldClose() { return glfwWindowShouldClose(window); } + /** + * Poll events and report whether the OS has removed the drawable surface. + * Windows commonly reports a 0x0 framebuffer while minimized; several GL + * drivers crash hard if we keep uploading/drawing/swap-buffering then. + */ + public boolean isRenderPaused() { + glfwPollEvents(); + if (window == NULL || windowIconified || glfwGetWindowAttrib(window, GLFW_ICONIFIED) == GLFW_TRUE) { + ClientDebugger.onRenderPauseState(true, "iconified", framebufferW, framebufferH); + sleepWhileRenderPaused(); + return true; + } + int[] fw = new int[1], fh = new int[1]; + glfwGetFramebufferSize(window, fw, fh); + framebufferW = fw[0]; + framebufferH = fh[0]; + boolean paused = framebufferW <= 0 || framebufferH <= 0; + ClientDebugger.onRenderPauseState(paused, paused ? "zero-framebuffer" : "drawable", + framebufferW, framebufferH); + if (paused) { + sleepWhileRenderPaused(); + } + return paused; + } + @Override public void beginFrame() { beginFrame(true); @@ -757,6 +790,10 @@ public void beginFrame(boolean clearViewport) { public void beginFrame(boolean clearViewport, boolean clearScene) { glfwPollEvents(); + frameDrawable = !isRenderPaused(); + if (!frameDrawable) { + return; + } if (clearScene) { glClear(GL_COLOR_BUFFER_BIT); } @@ -775,7 +812,15 @@ public void beginFrame(boolean clearViewport, boolean clearScene) { @Override public void endFrame() { + if (!frameDrawable) { + return; + } flushBatch(); + if (restoreCooldownFrames > 0) { + restoreCooldownFrames--; + glfwSwapBuffers(window); + return; + } drawUIOverlay(); sampledFrames++; updateMetrics(); @@ -783,6 +828,45 @@ public void endFrame() { glfwSwapBuffers(window); } + private void sleepWhileRenderPaused() { + try { + Thread.sleep(50L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + public boolean isFrameDrawable() { + return frameDrawable; + } + + public boolean shouldSuppressInterpolation() { + return !isHighFpsEffectiveEnabled(); + } + + private void beginRestoreCooldown() { + restoreCooldownFrames = Math.max(restoreCooldownFrames, 30); + settingFps60SuppressedUntilMs = Math.max(settingFps60SuppressedUntilMs, + System.currentTimeMillis() + 1000L); + restoreSceneGlState(); + } + + public static boolean isHighFpsEffectiveEnabled() { + return settingFps60Enabled && System.currentTimeMillis() >= settingFps60SuppressedUntilMs; + } + + private void restoreSceneGlState() { + glDisable(GL_DEPTH_TEST); + glDisable(GL_CULL_FACE); + glDisable(GL_SCISSOR_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glActiveTexture(GL_TEXTURE0); + glUseProgram(prog); + glUniform2f(uScreen, screenW, screenH); + glUniform1i(uTex, 0); + } + public void recordTick() { sampledTicks++; } @@ -876,12 +960,13 @@ public void uploadTexture(int texId) { ByteBuffer rgba = MemoryUtil.memAlloc(size * size * 4); try { + boolean transparent = Pix3D.textureTranslucent[texId]; for (int i = 0; i < size * size; i++) { int c = texels[i]; rgba.put((byte) (c >> 16)); // R rgba.put((byte) (c >> 8)); // G rgba.put((byte) c); // B - rgba.put(c == 0 ? (byte) 0 : (byte) -1); // A: 0 = transparent + rgba.put(transparent && c == 0 ? (byte) 0 : (byte) -1); } rgba.flip(); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, @@ -2708,7 +2793,9 @@ private void drawSettingsPanel(int x) { y = drawSettingsSectionTitle(x, y, "Client Settings"); y = drawSettingsToggleRow(x, y, "60 Fps Mode", sidebarFpsEnabled); - drawSettingsToggleRow(x, y, "Fullscreen Mode", settingsFullscreen); + y = drawSettingsToggleRow(x, y, "Fullscreen Mode", settingsFullscreen); + y += 4; + drawClientVersionText(x, y); } private void loadSettings() { @@ -2741,6 +2828,11 @@ private int drawSettingsToggleRow(int x, int y, String text, boolean enabled) { return y + 20; } + private void drawClientVersionText(int x, int y) { + int panelW = sidebarPanelW(); + drawUiTextFittedFull(clientVersionText, x + 16, y + 5, panelW - 32, 0, 0xFF999999); + } + private void drawSelectBox(int x, int y, int w, String text, boolean open) { fillUiRect(x, y, w, 18, 0xFF202020); fillUiRect(x, y, w, 1, 0xFF666666); @@ -3230,8 +3322,29 @@ private void setupCallbacks() { } }); - glfwSetFramebufferSizeCallback(window, (win, width, height) -> - updateOutputViewport(width, height)); + glfwSetFramebufferSizeCallback(window, (win, width, height) -> { + framebufferW = width; + framebufferH = height; + frameDrawable = !windowIconified && width > 0 && height > 0; + ClientDebugger.onRenderPauseState(!frameDrawable, + frameDrawable ? "drawable" : "framebuffer-callback", + framebufferW, framebufferH); + if (frameDrawable) { + beginRestoreCooldown(); + updateOutputViewport(width, height); + } + }); + glfwSetWindowIconifyCallback(window, (win, iconified) -> { + windowIconified = iconified; + frameDrawable = !iconified && framebufferW > 0 && framebufferH > 0; + ClientDebugger.onRenderPauseState(!frameDrawable, + iconified ? "iconify-callback" : "restore-callback", + framebufferW, framebufferH); + if (frameDrawable) { + beginRestoreCooldown(); + updateOutputViewport(); + } + }); glfwSetWindowSizeCallback(window, (win, width, height) -> { windowW = width; windowH = height; @@ -3617,7 +3730,7 @@ private void clickSettingsPanel(int x, int y) { rowY += 18; if (toggleHit(px, rowY, x, y)) { setFps60(!sidebarFpsEnabled); return; } rowY += 20; - if (toggleHit(px, rowY, x, y)) toggleFullscreen(); + if (toggleHit(px, rowY, x, y)) { toggleFullscreen(); return; } } private boolean toggleHit(int px, int rowY, int mouseX, int mouseY) { diff --git a/src/main/java/com/gradwahl/rs254/update/ClientUpdater.java b/src/main/java/com/gradwahl/rs254/update/ClientUpdater.java new file mode 100644 index 0000000..9112afa --- /dev/null +++ b/src/main/java/com/gradwahl/rs254/update/ClientUpdater.java @@ -0,0 +1,339 @@ +package com.gradwahl.rs254.update; + +import com.gradwahl.rs254.Main; + +import java.io.File; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class ClientUpdater { + private static final String LATEST_RELEASE_API = + "https://api.github.com/repos/2004sp/Progressive-Java-Client/releases/latest"; + private static final String UPDATER_JAR = "Progressive-Java-Updater.jar"; + private static final String UPDATER_RESOURCE = "/" + UPDATER_JAR; + + private ClientUpdater() {} + + /** + * Drops the bundled updater jar onto disk beside the running client. The client jar ships the + * updater inside it as a root resource; the updater must live as its own file because + * {@link #apply} launches it in a separate JVM after this client exits. Safe to call on every + * startup: it overwrites any stale copy and no-ops when running from unpacked classes (dev) or + * when the resource is absent. + */ + public static void ensureUpdaterExtracted() { + File current = currentBinary(); + if (current == null) { + return; + } + Path updater = current.toPath().getParent().resolve(UPDATER_JAR); + try (InputStream in = ClientUpdater.class.getResourceAsStream(UPDATER_RESOURCE)) { + if (in == null) { + return; + } + Files.copy(in, updater, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception ignored) { + } + } + + public record UpdateInfo(String tagName, String publishedAt, String assetName, + String assetUrl, boolean updateAvailable) {} + + public static UpdateInfo checkLatest() throws Exception { + String json = httpGet(LATEST_RELEASE_API); + String tag = jsonString(json, "tag_name"); + String publishedAt = jsonString(json, "published_at"); + List assets = parseAssets(json); + ReleaseAsset asset = selectAsset(assets); + if (tag == null || publishedAt == null || asset == null) { + throw new IllegalStateException("Latest release has no usable JAR/EXE asset"); + } + return new UpdateInfo(tag, publishedAt, asset.name(), asset.url(), + isNewerThanCurrent(tag, publishedAt)); + } + + public static void apply(UpdateInfo info) throws Exception { + File current = currentBinary(); + if (current == null || !current.isFile()) { + throw new IllegalStateException("Updater can only replace a packaged JAR or EXE"); + } + + Path dir = current.toPath().getParent(); + Path download = dir.resolve(current.getName() + ".download"); + Path updater = dir.resolve(UPDATER_JAR); + if (!Files.isRegularFile(updater)) { + ensureUpdaterExtracted(); + } + if (!Files.isRegularFile(updater)) { + throw new IllegalStateException("Missing " + UPDATER_JAR + " beside the client. Rebuild with build.bat/build.sh."); + } + + download(info.assetUrl(), download); + + List command = new ArrayList<>(); + command.add(javawPath()); + command.add("-jar"); + command.add(updater.toString()); + command.add(String.valueOf(ProcessHandle.current().pid())); + command.add(current.toPath().toString()); + command.add(download.toString()); + command.add("--"); + command.addAll(restartCommand(current.toPath())); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(dir.toFile()); + pb.start(); + System.exit(0); + } + + public static String currentVersionLabel() { + BuildInfo info = currentBuildInfo(); + if (info.version() != null && !info.version().isBlank()) { + return info.version(); + } + return "dev"; + } + + private static boolean isNewerThanCurrent(String releaseTag, String publishedAt) { + BuildInfo current = currentBuildInfo(); + String currentVersion = configVersion(); + if (currentVersion == null || currentVersion.isBlank()) { + currentVersion = current.version(); + } + // When we know the current version (from config.json or the manifest), it is authoritative: + // only update when the release tag is strictly newer. This stops re-downloading the same version. + if (currentVersion != null && !currentVersion.isBlank()) { + return compareVersions(releaseTag, currentVersion) > 0; + } + if (current.buildTime() != null) { + try { + return Instant.parse(publishedAt).isAfter(current.buildTime()); + } catch (Exception ignored) { + } + } + return true; + } + + /** Reads the version recorded in config.json beside the running client, or null if unavailable. */ + private static String configVersion() { + try { + File current = currentBinary(); + if (current == null) { + return null; + } + Path config = current.toPath().getParent().resolve("config.json"); + if (!Files.isRegularFile(config)) { + return null; + } + return jsonString(Files.readString(config, StandardCharsets.UTF_8), "version"); + } catch (Exception ignored) { + return null; + } + } + + private static BuildInfo currentBuildInfo() { + try { + String version = Main.class.getPackage().getImplementationVersion(); + String buildTime = null; + URL manifestUrl = Main.class.getResource("/META-INF/MANIFEST.MF"); + if (manifestUrl != null) { + try (InputStream in = manifestUrl.openStream()) { + Attributes attrs = new Manifest(in).getMainAttributes(); + buildTime = attrs.getValue("Build-Time"); + } + } + Instant instant = null; + if (buildTime != null && !buildTime.isBlank()) { + instant = Instant.parse(buildTime); + } + return new BuildInfo(version, instant); + } catch (Exception ignored) { + return new BuildInfo(null, null); + } + } + + private static File currentBinary() { + try { + File file = new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + String name = file.getName().toLowerCase(Locale.ROOT); + return (file.isFile() && (name.endsWith(".jar") || name.endsWith(".exe"))) ? file : null; + } catch (Exception ignored) { + return null; + } + } + + private static void download(String url, Path destination) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(8000); + conn.setReadTimeout(30000); + conn.setRequestProperty("User-Agent", "Progressive-Java-Client-Updater"); + try (InputStream in = conn.getInputStream()) { + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + } finally { + conn.disconnect(); + } + } + + private static List restartCommand(Path current) { + String currentName = current.toString().toLowerCase(Locale.ROOT); + if (!currentName.endsWith(".jar")) { + return List.of(current.toString()); + } + + List command = new ArrayList<>(); + command.add(javawPath()); + + String[] processArgs = ProcessHandle.current().info().arguments().orElse(null); + int jarIndex = indexOfJarFlag(processArgs); + if (jarIndex >= 0 && jarIndex + 1 < processArgs.length) { + for (int i = 0; i < jarIndex; i++) { + command.add(processArgs[i]); + } + command.add("-jar"); + command.add(current.toString()); + for (int i = jarIndex + 2; i < processArgs.length; i++) { + command.add(processArgs[i]); + } + return command; + } + + command.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + command.add("-Xmx1g"); + command.add("-Dsun.java2d.noddraw=true"); + command.add("--enable-native-access=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang.reflect=ALL-UNNAMED"); + command.add("-jar"); + command.add(current.toString()); + return command; + } + + private static int indexOfJarFlag(String[] args) { + if (args == null) return -1; + for (int i = 0; i < args.length; i++) { + if ("-jar".equalsIgnoreCase(args[i])) { + return i; + } + } + return -1; + } + + private static String javawPath() { + File javaw = new File(System.getProperty("java.home"), "bin" + File.separator + (isWindows() ? "javaw.exe" : "javaw")); + if (javaw.isFile()) { + return javaw.getPath(); + } + File java = new File(System.getProperty("java.home"), "bin" + File.separator + (isWindows() ? "java.exe" : "java")); + return java.isFile() ? java.getPath() : (isWindows() ? "javaw" : "java"); + } + + private static String httpGet(String url) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(8000); + conn.setReadTimeout(12000); + conn.setRequestProperty("User-Agent", "Progressive-Java-Client-Updater"); + try (InputStream in = conn.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } finally { + conn.disconnect(); + } + } + + private static ReleaseAsset selectAsset(List assets) { + String wantedExt = ".jar"; + File current = currentBinary(); + if (current != null && current.getName().toLowerCase(Locale.ROOT).endsWith(".exe")) { + wantedExt = ".exe"; + } + String ext = wantedExt; + return assets.stream() + .filter(asset -> asset.name().toLowerCase(Locale.ROOT).endsWith(ext)) + .min(Comparator.comparing(ReleaseAsset::name)) + .orElseGet(() -> assets.stream() + .filter(asset -> asset.name().toLowerCase(Locale.ROOT).endsWith(".jar")) + .findFirst() + .orElse(null)); + } + + private static List parseAssets(String json) { + List assets = new ArrayList<>(); + Matcher assetsArray = Pattern.compile("\"assets\"\\s*:\\s*\\[(.*?)]\\s*,\\s*\"tarball_url\"", Pattern.DOTALL) + .matcher(json); + if (!assetsArray.find()) { + return assets; + } + String body = assetsArray.group(1); + Matcher urlMatcher = Pattern.compile("\"browser_download_url\"\\s*:\\s*\"([^\"]+)\"").matcher(body); + int searchFrom = 0; + while (urlMatcher.find()) { + String beforeUrl = body.substring(searchFrom, urlMatcher.start()); + Matcher nameMatcher = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"").matcher(beforeUrl); + String name = null; + while (nameMatcher.find()) { + name = nameMatcher.group(1); + } + if (name != null) { + assets.add(new ReleaseAsset(unescapeJson(name), unescapeJson(urlMatcher.group(1)))); + } + searchFrom = urlMatcher.end(); + } + return assets; + } + + private static String jsonString(String json, String key) { + Matcher matcher = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"]*)\"") + .matcher(json); + return matcher.find() ? unescapeJson(matcher.group(1)) : null; + } + + private static String unescapeJson(String value) { + return value.replace("\\/", "/").replace("\\\"", "\"").replace("\\\\", "\\"); + } + + private static int compareVersions(String releaseTag, String currentVersion) { + int[] release = versionParts(releaseTag); + int[] current = versionParts(currentVersion); + if (release.length == 0 || current.length == 0) return 0; + int len = Math.max(release.length, current.length); + for (int i = 0; i < len; i++) { + int a = i < release.length ? release[i] : 0; + int b = i < current.length ? current[i] : 0; + if (a != b) return Integer.compare(a, b); + } + return 0; + } + + private static int[] versionParts(String value) { + String normalized = value.toLowerCase(Locale.ROOT).replaceFirst("^[^0-9]+", ""); + Matcher matcher = Pattern.compile("\\d+").matcher(normalized); + List parts = new ArrayList<>(); + while (matcher.find() && parts.size() < 4) { + parts.add(Integer.parseInt(matcher.group())); + } + return parts.stream().mapToInt(Integer::intValue).toArray(); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + + private record ReleaseAsset(String name, String url) {} + private record BuildInfo(String version, Instant buildTime) {} +} diff --git a/src/main/java/com/gradwahl/rs254/update/UpdateHelper.java b/src/main/java/com/gradwahl/rs254/update/UpdateHelper.java new file mode 100644 index 0000000..b67556b --- /dev/null +++ b/src/main/java/com/gradwahl/rs254/update/UpdateHelper.java @@ -0,0 +1,512 @@ +package com.gradwahl.rs254.update; + +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.io.File; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class UpdateHelper { + private static final String LATEST_RELEASE_API = + "https://api.github.com/repos/2004sp/Progressive-Java-Client/releases/latest"; + private static final int MAX_REPLACE_ATTEMPTS = 60; + private static final long RETRY_DELAY_MS = 500L; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "update-helper"); + t.setDaemon(true); + return t; + }); + private final JLabel status = new JLabel("Click Check for updates.", SwingConstants.CENTER); + private final JButton checkButton = new JButton("Check for updates"); + private final JButton applyButton = new JButton("Apply update"); + + private JFrame frame; + private UpdateInfo updateInfo; + private Path clientFile; + + private UpdateHelper() {} + + public static void main(String[] args) { + new UpdateHelper().run(args); + } + + private void run(String[] args) { + if (args.length == 0) { + runStandaloneGui(); + return; + } + + try { + Request request = Request.parse(args); + showApplyWindow(); + waitForClient(request.clientPid()); + replaceClient(request.download(), request.current()); + relaunch(request.current(), request.restartCommand()); + closeWindow(); + } catch (Exception e) { + showError(e); + } + } + + private void runStandaloneGui() { + try { + showStandaloneWindow(); + } catch (Exception e) { + showError(e); + } + } + + private void showStandaloneWindow() throws Exception { + SwingUtilities.invokeAndWait(() -> { + frame = baseFrame(WindowConstants.EXIT_ON_CLOSE); + applyButton.setEnabled(false); + checkButton.addActionListener(e -> checkForUpdates()); + applyButton.addActionListener(e -> applyStandaloneUpdate()); + + JPanel buttons = new JPanel(); + buttons.add(checkButton); + buttons.add(applyButton); + frame.add(buttons, BorderLayout.SOUTH); + frame.setVisible(true); + }); + } + + private void showApplyWindow() throws Exception { + SwingUtilities.invokeAndWait(() -> { + frame = baseFrame(WindowConstants.DO_NOTHING_ON_CLOSE); + status.setText("Preparing update..."); + frame.setVisible(true); + }); + } + + private JFrame baseFrame(int closeOperation) { + JFrame window = new JFrame("Progressive Java Client Updater"); + window.setDefaultCloseOperation(closeOperation); + window.setLayout(new BorderLayout(10, 10)); + window.add(status, BorderLayout.CENTER); + window.setPreferredSize(new Dimension(420, 130)); + window.pack(); + window.setLocationRelativeTo(null); + return window; + } + + private void checkForUpdates() { + setBusy(true); + setStatus("Checking GitHub releases..."); + executor.execute(() -> { + try { + clientFile = findClientFile(); + updateInfo = checkLatest(clientFile); + if (updateInfo.updateAvailable()) { + setStatus("Current: " + updateInfo.currentVersion() + " | Latest: " + updateInfo.tagName()); + SwingUtilities.invokeLater(() -> applyButton.setEnabled(true)); + } else { + setStatus("Up to date. Current: " + updateInfo.currentVersion() + " | Latest: " + updateInfo.tagName()); + SwingUtilities.invokeLater(() -> applyButton.setEnabled(false)); + } + } catch (Exception e) { + updateInfo = null; + SwingUtilities.invokeLater(() -> applyButton.setEnabled(false)); + showError(e); + } finally { + setBusy(false); + } + }); + } + + private void applyStandaloneUpdate() { + UpdateInfo info = updateInfo; + Path current = clientFile; + if (info == null || current == null) { + setStatus("Check for updates first."); + return; + } + + setBusy(true); + setStatus("Downloading " + info.assetName() + "..."); + executor.execute(() -> { + Path download = current.resolveSibling(current.getFileName() + ".download"); + try { + download(info.assetUrl(), download); + replaceClient(download, current); + setStatus("Update applied."); + relaunch(current, standaloneRestartCommand(current)); + closeWindow(); + } catch (Exception e) { + showError(e); + } finally { + setBusy(false); + } + }); + } + + private void setBusy(boolean busy) { + SwingUtilities.invokeLater(() -> { + checkButton.setEnabled(!busy); + applyButton.setEnabled(!busy && updateInfo != null && updateInfo.updateAvailable()); + }); + } + + private void setStatus(String text) { + SwingUtilities.invokeLater(() -> status.setText(text)); + } + + private void waitForClient(long pid) throws Exception { + setStatus("Waiting for the client to close..."); + Optional handle = ProcessHandle.of(pid); + if (handle.isPresent() && handle.get().isAlive()) { + handle.get().onExit().get(); + } + Thread.sleep(500L); + } + + private void replaceClient(Path download, Path current) throws Exception { + setStatus("Installing update..."); + Exception last = null; + for (int i = 0; i < MAX_REPLACE_ATTEMPTS; i++) { + try { + Files.move(download, current, StandardCopyOption.REPLACE_EXISTING); + return; + } catch (Exception e) { + last = e; + setStatus("Waiting for file lock... " + (i + 1) + "/" + MAX_REPLACE_ATTEMPTS); + Thread.sleep(RETRY_DELAY_MS); + } + } + throw new IllegalStateException("Could not replace the client. Close any remaining client windows and try again.", last); + } + + private void relaunch(Path current, List restartCommand) throws Exception { + setStatus("Restarting client..."); + if (restartCommand.isEmpty()) { + throw new IllegalArgumentException("Missing restart command"); + } + new ProcessBuilder(restartCommand) + .directory(current.getParent().toFile()) + .start(); + } + + private void closeWindow() throws Exception { + setStatus("Done."); + Thread.sleep(300L); + SwingUtilities.invokeAndWait(() -> { + if (frame != null) { + frame.dispose(); + } + }); + executor.shutdown(); + } + + private void showError(Exception e) { + e.printStackTrace(System.err); + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + Runnable show = () -> JOptionPane.showMessageDialog( + frame, + message, + "Update failed", + JOptionPane.ERROR_MESSAGE + ); + if (SwingUtilities.isEventDispatchThread()) { + show.run(); + } else { + try { + SwingUtilities.invokeAndWait(show); + } catch (Exception ignored) { + } + } + } + + private static Path findClientFile() throws Exception { + Path dir = new File(UpdateHelper.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + .toPath() + .getParent(); + Path exact = dir.resolve("Progressive-Java-Client.jar"); + if (Files.isRegularFile(exact)) { + return exact; + } + + try (var stream = Files.list(dir)) { + return stream + .filter(Files::isRegularFile) + .filter(path -> { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.startsWith("progressive-java-client") + && name.endsWith(".jar") + && !name.contains("updater") + && !name.endsWith(".download"); + }) + .max(Comparator.comparing(path -> path.toFile().lastModified())) + .orElseThrow(() -> new IllegalStateException("Could not find Progressive-Java-Client.jar beside the updater.")); + } + } + + private static UpdateInfo checkLatest(Path current) throws Exception { + String json = httpGet(LATEST_RELEASE_API); + String tag = jsonString(json, "tag_name"); + String publishedAt = jsonString(json, "published_at"); + List assets = parseAssets(json); + ReleaseAsset asset = selectAsset(assets); + if (tag == null || publishedAt == null || asset == null) { + throw new IllegalStateException("Latest release has no usable JAR asset"); + } + BuildInfo currentInfo = currentBuildInfo(current); + String currentVersion = resolveCurrentVersion(current, currentInfo); + boolean updateAvailable = isNewerThanCurrent(current, currentVersion, currentInfo, tag, publishedAt, asset.size()); + String label = (currentVersion != null && !currentVersion.isBlank()) ? currentVersion : versionLabel(currentInfo); + return new UpdateInfo(tag, publishedAt, asset.name(), asset.url(), updateAvailable, label); + } + + /** Prefers the version recorded in config.json beside the client, falling back to the jar manifest. */ + private static String resolveCurrentVersion(Path current, BuildInfo currentInfo) { + String configVersion = configVersion(current); + if (configVersion != null && !configVersion.isBlank()) { + return configVersion; + } + return currentInfo.version(); + } + + private static String configVersion(Path current) { + try { + Path config = current.getParent().resolve("config.json"); + if (!Files.isRegularFile(config)) { + return null; + } + return jsonString(Files.readString(config, StandardCharsets.UTF_8), "version"); + } catch (Exception ignored) { + return null; + } + } + + private static boolean isNewerThanCurrent(Path current, String currentVersion, BuildInfo currentInfo, + String releaseTag, String publishedAt, long assetSize) { + // When we know the current version (from config.json or the manifest), it is authoritative: + // only update when the release tag is strictly newer. This stops re-downloading the same version. + if (currentVersion != null && !currentVersion.isBlank()) { + return compareVersions(releaseTag, currentVersion) > 0; + } + if (currentInfo.buildTime() != null) { + try { + return Instant.parse(publishedAt).isAfter(currentInfo.buildTime()); + } catch (Exception ignored) { + } + } + try { + if (assetSize > 0 && Files.size(current) != assetSize) { + return true; + } + } catch (Exception ignored) { + } + return true; + } + + private static String versionLabel(BuildInfo info) { + if (info.version() != null && !info.version().isBlank()) { + return info.version(); + } + if (info.buildTime() != null) { + return info.buildTime().toString(); + } + return "unknown"; + } + + private static BuildInfo currentBuildInfo(Path current) { + try (JarFile jar = new JarFile(current.toFile())) { + Attributes attrs = jar.getManifest().getMainAttributes(); + String version = attrs.getValue("Implementation-Version"); + String buildTime = attrs.getValue("Build-Time"); + Instant instant = null; + if (buildTime != null && !buildTime.isBlank()) { + instant = Instant.parse(buildTime); + } + return new BuildInfo(version, instant); + } catch (Exception ignored) { + return new BuildInfo(null, null); + } + } + + private static void download(String url, Path destination) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(8000); + conn.setReadTimeout(30000); + conn.setRequestProperty("User-Agent", "Progressive-Java-Client-Updater"); + try (InputStream in = conn.getInputStream()) { + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + } finally { + conn.disconnect(); + } + } + + private static String httpGet(String url) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(8000); + conn.setReadTimeout(12000); + conn.setRequestProperty("User-Agent", "Progressive-Java-Client-Updater"); + try (InputStream in = conn.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } finally { + conn.disconnect(); + } + } + + private static ReleaseAsset selectAsset(List assets) { + return assets.stream() + .filter(asset -> asset.name().toLowerCase(Locale.ROOT).endsWith(".jar")) + .min(Comparator.comparing(ReleaseAsset::name)) + .orElse(null); + } + + private static List parseAssets(String json) { + List assets = new ArrayList<>(); + Matcher assetsArray = Pattern.compile("\"assets\"\\s*:\\s*\\[(.*?)]\\s*,\\s*\"tarball_url\"", Pattern.DOTALL) + .matcher(json); + if (!assetsArray.find()) { + return assets; + } + String body = assetsArray.group(1); + Matcher urlMatcher = Pattern.compile("\"browser_download_url\"\\s*:\\s*\"([^\"]+)\"").matcher(body); + int searchFrom = 0; + while (urlMatcher.find()) { + String beforeUrl = body.substring(searchFrom, urlMatcher.start()); + Matcher nameMatcher = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"").matcher(beforeUrl); + String name = null; + while (nameMatcher.find()) { + name = nameMatcher.group(1); + } + if (name != null) { + long size = latestLong(beforeUrl, "size"); + assets.add(new ReleaseAsset(unescapeJson(name), unescapeJson(urlMatcher.group(1)), size)); + } + searchFrom = urlMatcher.end(); + } + return assets; + } + + private static String jsonString(String json, String key) { + Matcher matcher = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"]*)\"") + .matcher(json); + return matcher.find() ? unescapeJson(matcher.group(1)) : null; + } + + private static long latestLong(String json, String key) { + Matcher matcher = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(\\d+)") + .matcher(json); + long value = -1L; + while (matcher.find()) { + value = Long.parseLong(matcher.group(1)); + } + return value; + } + + private static String unescapeJson(String value) { + return value.replace("\\/", "/").replace("\\\"", "\"").replace("\\\\", "\\"); + } + + private static int compareVersions(String releaseTag, String currentVersion) { + int[] release = versionParts(releaseTag); + int[] current = versionParts(currentVersion); + if (release.length == 0 || current.length == 0) return 0; + int len = Math.max(release.length, current.length); + for (int i = 0; i < len; i++) { + int a = i < release.length ? release[i] : 0; + int b = i < current.length ? current[i] : 0; + if (a != b) return Integer.compare(a, b); + } + return 0; + } + + private static int[] versionParts(String value) { + String normalized = value.toLowerCase(Locale.ROOT).replaceFirst("^[^0-9]+", ""); + Matcher matcher = Pattern.compile("\\d+").matcher(normalized); + List parts = new ArrayList<>(); + while (matcher.find() && parts.size() < 4) { + parts.add(Integer.parseInt(matcher.group())); + } + return parts.stream().mapToInt(Integer::intValue).toArray(); + } + + private static List standaloneRestartCommand(Path current) { + if (!current.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) { + return List.of(current.toString()); + } + List command = new ArrayList<>(); + command.add(javawPath()); + command.add("-Xmx1g"); + command.add("-Dsun.java2d.noddraw=true"); + command.add("--enable-native-access=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang=ALL-UNNAMED"); + command.add("--add-opens"); + command.add("java.base/java.lang.reflect=ALL-UNNAMED"); + command.add("-jar"); + command.add(current.toString()); + command.add("10"); + command.add("0"); + command.add("highmem"); + command.add("members"); + command.add("32"); + return command; + } + + private static String javawPath() { + File javaw = new File(System.getProperty("java.home"), "bin" + File.separator + (isWindows() ? "javaw.exe" : "javaw")); + if (javaw.isFile()) { + return javaw.getPath(); + } + File java = new File(System.getProperty("java.home"), "bin" + File.separator + (isWindows() ? "java.exe" : "java")); + return java.isFile() ? java.getPath() : (isWindows() ? "javaw" : "java"); + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + } + + private record Request(long clientPid, Path current, Path download, List restartCommand) { + private static Request parse(String[] args) { + int separator = Arrays.asList(args).indexOf("--"); + if (separator < 3) { + throw new IllegalArgumentException("Usage: updater -- "); + } + + long pid = Long.parseLong(args[0]); + Path current = Path.of(args[1]).toAbsolutePath(); + Path download = Path.of(args[2]).toAbsolutePath(); + List restart = new ArrayList<>(); + for (int i = separator + 1; i < args.length; i++) { + restart.add(args[i]); + } + return new Request(pid, current, download, restart); + } + } + + private record UpdateInfo(String tagName, String publishedAt, String assetName, + String assetUrl, boolean updateAvailable, String currentVersion) {} + private record ReleaseAsset(String name, String url, long size) {} + private record BuildInfo(String version, Instant buildTime) {} +} diff --git a/src/main/java/jagex2/client/Client.java b/src/main/java/jagex2/client/Client.java index da582bf..08d8cfa 100644 --- a/src/main/java/jagex2/client/Client.java +++ b/src/main/java/jagex2/client/Client.java @@ -1948,6 +1948,7 @@ public void loop() { glRenderer.recordTick(); } loopCycle++; + ClientDebugger.onLoopHeartbeat(loopCycle); if (this.ingame) { this.gameLoop(); } else { @@ -1964,13 +1965,18 @@ public void draw() { } if (glRenderer != null) { if (glRenderer.shouldClose()) { this.state = -1; return; } + if (glRenderer.isRenderPaused()) { return; } boolean drawScene = !this.ingame || this.sceneState == 2; glRenderer.beginFrame(this.ingame && drawScene, drawScene); + if (!glRenderer.isFrameDrawable()) { return; } } drawCycle++; + ClientDebugger.onDrawHeartbeat(drawCycle); // Publish the render-time interpolation state for entity model building. - ClientEntity.renderInterpOn = GLRenderer.settingFps60Enabled; - ClientEntity.renderInterp = GLRenderer.settingFps60Enabled ? super.subTickFraction : 0f; + boolean interpolateEntities = GLRenderer.isHighFpsEffectiveEnabled() + && (glRenderer == null || !glRenderer.shouldSuppressInterpolation()); + ClientEntity.renderInterpOn = interpolateEntities; + ClientEntity.renderInterp = interpolateEntities ? super.subTickFraction : 0f; if (this.ingame) { this.gameDraw(); } else { @@ -5029,7 +5035,7 @@ public void entityAnim(ClientEntity arg1) { @Override protected boolean isHighFpsEnabled() { - return GLRenderer.settingFps60Enabled; + return GLRenderer.isHighFpsEffectiveEnabled(); } private void updateEntityAnimationStep() { @@ -5536,14 +5542,11 @@ public void gameDrawMain() { var2 = this.cameraModifierWobbleScale[4] + 128; } int var3 = this.orbitCameraYaw + this.macroCameraAngle & 0x7FF; - // Interpolate the camera target (and the focus height from the local - // player's interpolated position) so the camera tracks smoothly at the - // render rate instead of stepping at 50fps. - int camX = this.interpOrbitCameraX(); - int camZ = this.interpOrbitCameraZ(); - int focusX = this.interpSceneX(localPlayer); - int focusZ = this.interpSceneZ(localPlayer); - this.camFollow(var2, camX, this.getAvH(focusZ, this.minusedlevel, focusX) - 50, camZ, var2 * 3 + 600, var3); + // Keep terrain camera/focus on exact tick positions. Sub-tick camera + // interpolation can feed intermediate values into the world visibility + // and projective texture math, causing transient black terrain flashes + // on some GPUs in 60 FPS mode. + this.camFollow(var2, this.orbitCameraX, this.getAvH(localPlayer.z, this.minusedlevel, localPlayer.x) - 50, this.orbitCameraZ, var2 * 3 + 600, var3); } int var4; if (this.cutscene) { diff --git a/src/main/java/jagex2/client/GameShell.java b/src/main/java/jagex2/client/GameShell.java index 29d167b..313eca0 100644 --- a/src/main/java/jagex2/client/GameShell.java +++ b/src/main/java/jagex2/client/GameShell.java @@ -154,11 +154,12 @@ public void run() { } long var7 = System.currentTimeMillis(); // High-FPS (interpolated render) bookkeeping. In this mode the game logic - // still ticks on the fixed server-compatible schedule, but draw() runs - // every monitor refresh and animations are interpolated between ticks. + // still ticks on the fixed server-compatible schedule, but draw() targets + // 60fps instead of inheriting 120/144/240Hz monitor refresh rates. long highFpsLast = System.currentTimeMillis(); long logicAccumMs = 0L; boolean wasHighFps = this.isHighFpsEnabled(); + long nextHighFpsFrame = System.currentTimeMillis(); while (true) { long var11; do { @@ -179,6 +180,7 @@ public void run() { if (highFpsEnabled != wasHighFps) { long now = System.currentTimeMillis(); highFpsLast = now; + nextHighFpsFrame = now; logicAccumMs = 0L; this.subTickFraction = 0f; if (!highFpsEnabled) { @@ -195,6 +197,15 @@ public void run() { if (highFpsEnabled) { // ---- decoupled path: fixed-timestep logic + interpolated draw ---- var11 = System.currentTimeMillis(); + long frameDelay = nextHighFpsFrame - var11; + if (frameDelay > 0L) { + try { + Thread.sleep(Math.min(frameDelay, 16L)); + } catch (InterruptedException ignored) { + } + var11 = System.currentTimeMillis(); + } + nextHighFpsFrame = var11 + 16L; long elapsed = var11 - highFpsLast; highFpsLast = var11; if (elapsed < 0L) { @@ -294,6 +305,7 @@ public void run() { // Keep the high-fps clock fresh so flipping the toggle on mid-session // doesn't replay a huge accumulated delta as a burst of logic ticks. highFpsLast = System.currentTimeMillis(); + nextHighFpsFrame = highFpsLast; logicAccumMs = 0L; } } while (!this.debug); diff --git a/src/main/java/jagex2/dash3d/World.java b/src/main/java/jagex2/dash3d/World.java index c26d344..f358f3a 100644 --- a/src/main/java/jagex2/dash3d/World.java +++ b/src/main/java/jagex2/dash3d/World.java @@ -1,5 +1,6 @@ package jagex2.dash3d; +import com.gradwahl.rs254.ClientDebugger; import deob.ObfuscatedName; import jagex2.datastruct.LinkList; import jagex2.graphics.Pix2D; @@ -1177,7 +1178,19 @@ public void renderAll(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5 @ObfuscatedName("s.a(Lw;Z)V") public void fill(Square arg0, boolean arg1) { fillQueue.push(arg0); + int guard = 0; while (true) { + if (++guard > 20000) { + ClientDebugger.log("[WORLD] fill guard tripped" + + " start=" + arg0.x + "," + arg0.z + "," + arg0.level + + " currentCameraTile=" + gx + "," + gz + + " topLevel=" + topLevel + + " fillLeft=" + fillLeft + + " cycleNo=" + cycleNo); + fillQueue.clear(); + fillLeft = 0; + return; + } Square var3; int var4; int var5;