A RuneTek3 client (377) that is deobfuscated, converted to Kotlin, and includes QoL improvements.
0

Configure Feed

Select the types of activity you want to include in your feed.

chore: update ci, update sound system

+147 -72
+2
.gitignore
··· 63 63 .classpath 64 64 .project 65 65 .settings 66 + 67 + *.log
+19 -1
.gitlab-ci.yml
··· 1 1 stages: 2 2 - build 3 + - publish 3 4 4 5 variables: 5 6 GRADLE_OPTS: "-Dorg.gradle.daemon=false" 7 + PACKAGE_NAME: "rs377-client" 8 + JAR_NAME: "377.jar" 6 9 7 10 build: 8 11 stage: build ··· 13 16 - ./gradlew clean jar 14 17 artifacts: 15 18 paths: 16 - - build/libs/377.jar 19 + - build/libs/${JAR_NAME} 17 20 expire_in: 90 days 18 21 cache: 19 22 key: ··· 21 24 - build.gradle 22 25 paths: 23 26 - .gradle/ 27 + 28 + publish: 29 + stage: publish 30 + image: curlimages/curl:latest 31 + needs: 32 + - job: build 33 + artifacts: true 34 + rules: 35 + - if: $CI_COMMIT_BRANCH == "main" 36 + script: 37 + - echo "Publishing ${PACKAGE_NAME} @ ${CI_COMMIT_SHORT_SHA}" 38 + - | 39 + curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \ 40 + --upload-file "build/libs/${JAR_NAME}" \ 41 + "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${CI_COMMIT_SHORT_SHA}/${JAR_NAME}"
+1 -1
build.gradle
··· 13 13 testImplementation 'org.testng:testng:7.9.0' 14 14 } 15 15 16 - test { 16 + tasks.withType(Test).configureEach { 17 17 useJUnitPlatform() 18 18 } 19 19
gradle/wrapper/gradle-wrapper.jar

This is a binary file and will not be displayed.

-7
gradle/wrapper/gradle-wrapper.properties
··· 1 - distributionBase=GRADLE_USER_HOME 2 - distributionPath=wrapper/dists 3 - distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 - networkTimeout=10000 5 - validateDistributionUrl=true 6 - zipStoreBase=GRADLE_USER_HOME 7 - zipStorePath=wrapper/dists
+2 -2
gradlew
··· 114 114 NONSTOP* ) nonstop=true ;; 115 115 esac 116 116 117 - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 117 + CLASSPATH="\\\"\\\"" 118 118 119 119 120 120 # Determine the Java command to use to start the JVM. ··· 213 213 set -- \ 214 214 "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 215 -classpath "$CLASSPATH" \ 216 - org.gradle.wrapper.GradleWrapperMain \ 216 + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 217 "$@" 218 218 219 219 # Stop when "xargs" is not available.
+2 -2
gradlew.bat
··· 70 70 :execute 71 71 @rem Setup the command line 72 72 73 - set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 + set CLASSPATH= 74 74 75 75 76 76 @rem Execute Gradle 77 - "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 77 + "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 78 79 79 :end 80 80 @rem End local scope for the variables with windows NT shell
+43 -2
src/main/java/com/jagex/runescape/Game.java
··· 5626 5626 firstMenuOperand[menuActionRow] = l2; 5627 5627 secondMenuOperand[menuActionRow] = child.id; 5628 5628 menuActionRow++; 5629 + 5630 + // Shift-drop: promote Drop to left-click when Shift is held 5631 + if (super.keyStatus[6] == 1 && child.isInventory) { 5632 + for (int sd = menuActionRow - 2; sd >= 0; sd--) { 5633 + if (menuActionTypes[sd] == 891) { 5634 + String tmpText = menuActionTexts[menuActionRow - 1]; 5635 + int tmpType = menuActionTypes[menuActionRow - 1]; 5636 + int tmpAction = selectedMenuActions[menuActionRow - 1]; 5637 + int tmpFirst = firstMenuOperand[menuActionRow - 1]; 5638 + int tmpSecond = secondMenuOperand[menuActionRow - 1]; 5639 + 5640 + menuActionTexts[menuActionRow - 1] = menuActionTexts[sd]; 5641 + menuActionTypes[menuActionRow - 1] = menuActionTypes[sd]; 5642 + selectedMenuActions[menuActionRow - 1] = selectedMenuActions[sd]; 5643 + firstMenuOperand[menuActionRow - 1] = firstMenuOperand[sd]; 5644 + secondMenuOperand[menuActionRow - 1] = secondMenuOperand[sd]; 5645 + 5646 + menuActionTexts[sd] = tmpText; 5647 + menuActionTypes[sd] = tmpType; 5648 + selectedMenuActions[sd] = tmpAction; 5649 + firstMenuOperand[sd] = tmpFirst; 5650 + secondMenuOperand[sd] = tmpSecond; 5651 + break; 5652 + } 5653 + } 5654 + } 5629 5655 } 5630 5656 } 5631 5657 } ··· 10344 10370 int itemId = opcodes[counter++] + 1; 10345 10371 if (itemId >= 0 && itemId < ItemDefinition.count && (!ItemDefinition.lookup(itemId).members || memberServer)) { 10346 10372 for (int item = 0; item < widget1.items.length; item++) { 10347 - if (widget1.items[item] == itemId) { 10373 + if (widget1.items[item] != itemId) { 10348 10374 continue; 10349 10375 } 10350 - value = 0; 10376 + value = 999999999; 10351 10377 break; 10352 10378 } 10353 10379 ··· 11491 11517 if (minimapState != 0) 11492 11518 return; 11493 11519 if (super.clickType == 1) { 11520 + // Compass click: reset camera orientation and zoom 11521 + int compassX = super.clickX - 550; 11522 + int compassY = super.clickY - 4; 11523 + if (compassX >= 0 && compassY >= 0 && compassX < 33 && compassY < 33) { 11524 + int cdx = compassX - 16; 11525 + int cdy = compassY - 16; 11526 + if (cdx * cdx + cdy * cdy <= 16 * 16) { 11527 + cameraHorizontal = 0; 11528 + cameraVelocityHorizontal = 0; 11529 + cameraVelocityVertical = 0; 11530 + super.cameraZoom = 600; 11531 + return; 11532 + } 11533 + } 11534 + 11494 11535 int i = super.clickX - 25 - 550; 11495 11536 int j = super.clickY - 5 - 4; 11496 11537 if (i >= 0 && j >= 0 && i < 146 && j < 151) {
+4
src/main/java/com/jagex/runescape/GameShell.java
··· 312 312 keyChar = 4; 313 313 if (keyCode == 17) 314 314 keyChar = 5; 315 + if (keyCode == 16) 316 + keyChar = 6; 315 317 if (keyCode == 8) 316 318 keyChar = 8; 317 319 if (keyCode == 127) ··· 354 356 keyChar = '\004'; 355 357 if (keyCode == 17) 356 358 keyChar = '\005'; 359 + if (keyCode == 16) 360 + keyChar = '\006'; 357 361 if (keyCode == 8) 358 362 keyChar = '\b'; 359 363 if (keyCode == 127)
+7
src/main/java/com/jagex/runescape/cache/media/Widget.java
··· 367 367 temp[1] = (byte) parentId; 368 368 } 369 369 Widget.mediaArchive = null; 370 + 371 + // Enable item dragging on the bank items grid (5382). 372 + // The cache definition has itemSwapable=false, preventing the client 373 + // from sending swap packets to the server for bank rearrangement. 374 + if (interfaces.length > 5382 && interfaces[5382] != null) { 375 + interfaces[5382].itemSwapable = true; 376 + } 370 377 } 371 378 372 379 public static void method200(int i) {
+33 -40
src/main/java/com/jagex/runescape/sound/SoundPlayer.java
··· 1 1 package com.jagex.runescape.sound; 2 2 3 3 import java.io.InputStream; 4 + import java.util.Arrays; 4 5 import java.util.concurrent.ConcurrentLinkedQueue; 5 6 import javax.sound.sampled.AudioFormat; 6 7 import javax.sound.sampled.AudioSystem; 7 - import javax.sound.sampled.FloatControl; 8 8 import javax.sound.sampled.SourceDataLine; 9 9 import com.jagex.runescape.util.SignLink; 10 10 ··· 12 12 * Persistent audio mixer that keeps a single SourceDataLine open to avoid 13 13 * macOS CoreAudio pops caused by repeated device activation/deactivation. 14 14 * 15 - * Sounds are queued and mixed in a background thread. The line stays open 16 - * and writes silence between sounds to keep the audio device active. 15 + * Sounds are queued and mixed in a background thread. Uses 16-bit signed 16 + * output for clean mixing headroom even when multiple sounds overlap. 17 17 */ 18 18 public class SoundPlayer implements Runnable { 19 19 20 20 private static final int SAMPLE_RATE = 22050; 21 21 private static final int WAV_HEADER_SIZE = 44; 22 - private static final int BUFFER_SIZE = 2048; 22 + private static final int SAMPLES_PER_BUFFER = 1024; 23 + private static final int BUFFER_SIZE = SAMPLES_PER_BUFFER * 2; // 16-bit = 2 bytes/sample 23 24 24 25 private static final AudioFormat FORMAT = 25 - new AudioFormat(SAMPLE_RATE, 8, 1, false, false); 26 + new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // 16-bit signed LE mono 26 27 27 28 private static SourceDataLine line; 28 29 private static Thread mixerThread; ··· 35 36 private static class QueuedSound { 36 37 final byte[] pcm; 37 38 int position; 38 - final int delay; 39 39 int delayRemaining; 40 40 41 41 QueuedSound(byte[] pcm, int delay) { 42 42 this.pcm = pcm; 43 43 this.position = 0; 44 - this.delay = delay; 45 44 this.delayRemaining = delay; 46 45 } 47 46 ··· 59 58 return; 60 59 } 61 60 try { 62 - // Read the WAV data and strip the header to get raw PCM 63 61 byte[] wavData = readFully(stream); 64 62 if (wavData.length <= WAV_HEADER_SIZE) { 65 63 return; ··· 67 65 byte[] pcm = new byte[wavData.length - WAV_HEADER_SIZE]; 68 66 System.arraycopy(wavData, WAV_HEADER_SIZE, pcm, 0, pcm.length); 69 67 70 - // Convert delay from ms to samples 71 68 int delaySamples = (delay * SAMPLE_RATE) / 1000; 72 69 73 70 soundQueue.add(new QueuedSound(pcm, delaySamples)); ··· 95 92 public void run() { 96 93 try { 97 94 line = AudioSystem.getSourceDataLine(FORMAT); 98 - line.open(FORMAT, SAMPLE_RATE); // 1-second buffer 95 + line.open(FORMAT, SAMPLE_RATE * 2); // 1-second buffer at 16-bit 99 96 line.start(); 100 97 101 98 byte[] buffer = new byte[BUFFER_SIZE]; ··· 109 106 idleSamples = 0; 110 107 } 111 108 112 - // Process delay countdowns 109 + // Tick delay countdowns (in samples, not bytes) 113 110 for (QueuedSound s : activeSounds) { 114 111 if (s.delayRemaining > 0) { 115 - s.delayRemaining -= BUFFER_SIZE; 112 + s.delayRemaining -= SAMPLES_PER_BUFFER; 116 113 } 117 114 } 118 115 119 116 if (activeSounds.isEmpty()) { 120 - // Write silence to keep line open; after 5 seconds of 121 - // silence, stop writing (line stays open but idle) 122 - idleSamples += BUFFER_SIZE; 123 - if (idleSamples < SAMPLE_RATE * 300) { // 5 minutes 124 - java.util.Arrays.fill(buffer, (byte) 128); 117 + idleSamples += SAMPLES_PER_BUFFER; 118 + if (idleSamples < SAMPLE_RATE * 2) { 119 + // Write silence for 2 seconds to keep device active 120 + Arrays.fill(buffer, (byte) 0); 125 121 line.write(buffer, 0, BUFFER_SIZE); 126 122 } else { 127 - Thread.sleep(50); 123 + // Sleep to avoid spinning — line stays open 124 + Thread.sleep(10); 128 125 } 129 126 continue; 130 127 } 131 128 132 129 idleSamples = 0; 133 130 134 - // Get current volume scale (0-3 maps to 1.0, 0.66, 0.33, 0.0) 135 - float volumeScale = 1.0f - (volume / 4.0f); 136 - // Also apply waveVolume gain 137 - float waveGain = SignLink.waveVolume / 100.0f; 138 - // waveVolume is in centibels (dB * 100), convert to linear 139 - float linearGain = (float) Math.pow(10.0, waveGain / 20.0); 140 - float totalScale = volumeScale * Math.min(linearGain, 1.0f); 131 + // Volume: waveVolume is centibels (dB × 100), convert to linear 132 + float dB = SignLink.waveVolume / 100.0f; 133 + float gain = (float) Math.pow(10.0, dB / 20.0); 134 + if (gain > 1.0f) gain = 1.0f; 141 135 142 - // Mix active sounds into buffer 143 - for (int i = 0; i < BUFFER_SIZE; i++) { 136 + // Mix active sounds into 16-bit signed output 137 + for (int i = 0; i < SAMPLES_PER_BUFFER; i++) { 144 138 int mixed = 0; 145 - int count = 0; 146 139 147 140 for (QueuedSound s : activeSounds) { 148 141 if (s.delayRemaining > 0 || !s.hasRemaining()) { 149 142 continue; 150 143 } 151 - // 8-bit unsigned → signed 152 - mixed += (s.pcm[s.position] & 0xFF) - 128; 144 + // Upscale 8-bit unsigned PCM to 16-bit signed 145 + mixed += ((s.pcm[s.position] & 0xFF) - 128) << 8; 153 146 s.position++; 154 - count++; 155 147 } 156 148 157 - if (count == 0) { 158 - buffer[i] = (byte) 128; // silence 159 - } else { 160 - // Apply volume, clamp, convert back to unsigned 161 - int scaled = (int) (mixed * totalScale); 162 - if (scaled > 127) scaled = 127; 163 - if (scaled < -128) scaled = -128; 164 - buffer[i] = (byte) (scaled + 128); 165 - } 149 + // Apply volume gain 150 + mixed = (int) (mixed * gain); 151 + 152 + // Clamp to 16-bit signed range 153 + if (mixed > 32767) mixed = 32767; 154 + if (mixed < -32768) mixed = -32768; 155 + 156 + // Write as little-endian 16-bit 157 + buffer[i * 2] = (byte) (mixed & 0xFF); 158 + buffer[i * 2 + 1] = (byte) ((mixed >> 8) & 0xFF); 166 159 } 167 160 168 161 // Remove finished sounds
+34 -17
src/main/java/com/jagex/runescape/util/SignLink.java
··· 45 45 public static String errorName = ""; 46 46 public static Sequencer music = null; 47 47 private static Synthesizer synthesizer = null; 48 + private static Receiver cachedReceiver = null; 48 49 private Position curPosition; 49 50 50 51 enum Position { ··· 181 182 } finally { 182 183 audioLine.drain(); 183 184 audioLine.close(); 185 + try { audioInputStream.close(); } catch (Exception ignored) {} 184 186 } 185 187 } 186 188 ··· 228 230 } 229 231 230 232 /** 231 - * Plays the specified midi sequence. 232 - * @param location 233 + * Plays the specified midi sequence. Closes any previous synthesizer 234 + * and receiver to prevent native audio resource leaks on macOS. 233 235 */ 234 236 private void playMidi(String location) { 235 - Sequence sequence; 237 + // Close previous synthesizer/receiver — the caller already stopped 238 + // the sequencer, but the synthesizer was left open and would leak 239 + // a CoreAudio instance on every song change. 240 + if (synthesizer != null && synthesizer != music) { 241 + try { synthesizer.close(); } catch (Exception ignored) {} 242 + } 243 + if (cachedReceiver != null) { 244 + try { cachedReceiver.close(); } catch (Exception ignored) {} 245 + cachedReceiver = null; 246 + } 247 + 248 + Sequence sequence; 236 249 music = null; 237 250 synthesizer = null; 238 251 File midiFile = new File(location); ··· 240 253 try { 241 254 sequence = MidiSystem.getSequence(midiFile); 242 255 music = MidiSystem.getSequencer(); 243 - 244 256 music.open(); 245 257 music.setSequence(sequence); 246 258 } catch (Exception e) { ··· 254 266 } else { 255 267 try { 256 268 synthesizer = MidiSystem.getSynthesizer(); 257 - 258 269 synthesizer.open(); 259 270 260 271 if (synthesizer.getDefaultSoundbank() == null) { 261 - music.getTransmitter().setReceiver(MidiSystem.getReceiver()); 272 + cachedReceiver = MidiSystem.getReceiver(); 273 + music.getTransmitter().setReceiver(cachedReceiver); 262 274 } else { 263 275 music.getTransmitter().setReceiver(synthesizer.getReceiver()); 264 276 } ··· 301 313 } 302 314 303 315 /** 304 - * Sets the volume for the midi synthesizer. 305 - * @param value 316 + * Sets the volume for the midi synthesizer via CC#7 (Main Volume). 317 + * 318 + * Previous bugs fixed: 319 + * - ShortMessage was reused: second setMessage() overwrote the first, 320 + * so CC#7 was never actually sent (only CC#39/LSB). Music played 321 + * near max volume regardless of the setting. 322 + * - MidiSystem.getReceiver() was called per-channel per-fade-step, 323 + * leaking hundreds of native receiver handles during a single fade. 324 + * - CC#39 (volume LSB) removed — 128 steps from CC#7 is sufficient. 306 325 */ 307 326 public static void setVolume(int value) { 308 - final int CHANGE_VOLUME = 7; 309 327 midiVolume = value; 310 328 329 + if (synthesizer == null) return; 330 + 311 331 if (synthesizer.getDefaultSoundbank() == null) { 332 + if (cachedReceiver == null) return; 312 333 try { 313 - ShortMessage volumeMessage = new ShortMessage(); 314 - 315 334 for (int i = 0; i < 16; i++) { 316 - volumeMessage.setMessage(ShortMessage.CONTROL_CHANGE, i, CHANGE_VOLUME, midiVolume); 317 - volumeMessage.setMessage(ShortMessage.CONTROL_CHANGE, i, 39, midiVolume); 318 - MidiSystem.getReceiver().send(volumeMessage, -1); 335 + ShortMessage msg = new ShortMessage(); 336 + msg.setMessage(ShortMessage.CONTROL_CHANGE, i, 7, midiVolume); 337 + cachedReceiver.send(msg, -1); 319 338 } 320 339 } catch (Exception e) { 321 340 e.printStackTrace(); 322 341 } 323 342 } else { 324 343 MidiChannel[] channels = synthesizer.getChannels(); 325 - 326 344 for (int c = 0; channels != null && c < channels.length; c++) { 327 - channels[c].controlChange(CHANGE_VOLUME, midiVolume); 328 - channels[c].controlChange(39, midiVolume); 345 + channels[c].controlChange(7, midiVolume); 329 346 } 330 347 } 331 348 }