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;