From 413f3c30cd422241d6c824fd3ded0932b314941a Mon Sep 17 00:00:00 2001 From: psyopcutie Date: Thu, 2 Jul 2026 14:13:50 +0200 Subject: [PATCH] music plugin --- client/src/main/java/plugin/api/API.java | 37 + client/src/main/java/rt4/ClientProt.java | 4 + client/src/main/java/rt4/Protocol.java | 5 +- client/src/main/java/rt4/SoundPlayer.java | 7 +- .../src/main/kotlin/SmartAudio/plugin.kt | 715 ++++++++++++++++++ .../main/kotlin/SmartAudio/plugin.properties | 3 + 6 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 plugin-playground/src/main/kotlin/SmartAudio/plugin.kt create mode 100644 plugin-playground/src/main/kotlin/SmartAudio/plugin.properties diff --git a/client/src/main/java/plugin/api/API.java b/client/src/main/java/plugin/api/API.java index cd7489c9..cdf17bb9 100644 --- a/client/src/main/java/plugin/api/API.java +++ b/client/src/main/java/plugin/api/API.java @@ -394,6 +394,43 @@ public class API { MidiPlayer.playFadeOut(trackId, client.js5Archive6, volume); } + /** + * Music hook used by the SmartAudio plugin. + */ + public interface AudioHandler { + boolean onMusicRequest(int trackId); + + boolean onMusicEnd(int trackId); + + default void onMusicSelect(int slot) {} + } + + private static AudioHandler audioHandler = null; + + public static void SetAudioHandler(AudioHandler handler) { + audioHandler = handler; + } + + public static void ClearAudioHandler(AudioHandler handler) { + if (audioHandler == handler) { + audioHandler = null; + } + } + + public static boolean MusicRequest(int trackId) { + return audioHandler == null || audioHandler.onMusicRequest(trackId); + } + + public static boolean MusicEnd(int trackId) { + return audioHandler == null || audioHandler.onMusicEnd(trackId); + } + + public static void MusicSelect(int slot) { + if (audioHandler != null) { + audioHandler.onMusicSelect(slot); + } + } + public static void SetLoginScreenMusicOnLoad(String song) { client.TITLE_SONG = JagString.parse(song); } diff --git a/client/src/main/java/rt4/ClientProt.java b/client/src/main/java/rt4/ClientProt.java index 4aaf548d..453b5411 100644 --- a/client/src/main/java/rt4/ClientProt.java +++ b/client/src/main/java/rt4/ClientProt.java @@ -3,6 +3,7 @@ package rt4; import org.openrs2.deob.annotation.OriginalArg; import org.openrs2.deob.annotation.OriginalMember; import org.openrs2.deob.annotation.Pc; +import plugin.api.API; import java.io.IOException; @@ -55,6 +56,9 @@ public class ClientProt { return; } if (arg2 == 1) { + if (arg3 == ((187 << 16) + 1)) { + API.MusicSelect(arg1); + } Protocol.outboundBuffer.p1isaac(155); Protocol.outboundBuffer.p4(arg3); Protocol.outboundBuffer.p2(arg1); diff --git a/client/src/main/java/rt4/Protocol.java b/client/src/main/java/rt4/Protocol.java index 1550121d..da3d0f67 100644 --- a/client/src/main/java/rt4/Protocol.java +++ b/client/src/main/java/rt4/Protocol.java @@ -4,6 +4,7 @@ import org.openrs2.deob.annotation.OriginalArg; import org.openrs2.deob.annotation.OriginalMember; import org.openrs2.deob.annotation.Pc; import plugin.PluginRepository; +import plugin.api.API; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -2320,7 +2321,9 @@ public class Protocol { if (id == 65535) { id = -1; } - MusicPlayer.playSong(id); + if (API.MusicRequest(id)) { + MusicPlayer.playSong(id); + } opcode = -1; return true; } else if (opcode == ServerProt.MIDI_JINGLE) { diff --git a/client/src/main/java/rt4/SoundPlayer.java b/client/src/main/java/rt4/SoundPlayer.java index e720a2af..5a424017 100644 --- a/client/src/main/java/rt4/SoundPlayer.java +++ b/client/src/main/java/rt4/SoundPlayer.java @@ -3,6 +3,7 @@ package rt4; import org.openrs2.deob.annotation.OriginalArg; import org.openrs2.deob.annotation.OriginalMember; import org.openrs2.deob.annotation.Pc; +import plugin.api.API; public class SoundPlayer { @OriginalMember(owner = "client!qe", name = "t", descriptor = "[I") @@ -128,7 +129,11 @@ public class SoundPlayer { } MidiPlayer.jingle = false; } else if (Preferences.musicVolume != 0 && MusicPlayer.groupId != -1 && !MidiPlayer.isPlaying()) { - sendTrackEndPacket(); + if (API.MusicEnd(MusicPlayer.groupId)) { + sendTrackEndPacket(); + } else { + MusicPlayer.groupId = -1; + } } } diff --git a/plugin-playground/src/main/kotlin/SmartAudio/plugin.kt b/plugin-playground/src/main/kotlin/SmartAudio/plugin.kt new file mode 100644 index 00000000..abf966c4 --- /dev/null +++ b/plugin-playground/src/main/kotlin/SmartAudio/plugin.kt @@ -0,0 +1,715 @@ +package SmartAudio + +import plugin.Plugin +import plugin.api.API +import rt4.Camera +import rt4.Component +import rt4.IntNode +import rt4.InterfaceList +import rt4.JagString +import rt4.MidiPlayer +import rt4.MusicPlayer +import rt4.PlayerList +import kotlin.random.Random + +class plugin : Plugin() { + + private var enabled = true + private var jinglesOnly = false + private var debug = true + + private var latestTrack = -1 + private var lastPlayedTrack = -1 + private var playingMusicLocation: MusicLocation? = null + private var currentMusicLocation: MusicLocation? = null + private var manualMusicSelect = false + private var manualMusicPlaying = false + private var intervalMillis = 20 * 1000L + private var nextMusicAt = 0L + + private var musicTextComponent: Component? = null + private var musicLabelsLoaded = false + private val MUSIC_PLAYER_TRACK_COMPONENT_ID = (187 shl 16) + 14 + private val MUSIC_BUTTON_TO_SONG_ENUM = 1351 + private val MUSIC_BUTTON_TO_NAME_ENUM = 1345 + private val musicLabels = HashMap() + + private val random = Random.Default + + private val NEWBIE_MELODY = 62 + + private val handler = object : API.AudioHandler { + override fun onMusicRequest(trackId: Int): Boolean { + if (!enabled) { + return true + } + + if (trackId == -1) { + latestTrack = -1 + manualMusicSelect = false + manualMusicPlaying = false + return true + } + + if (manualMusicSelect) { + manualMusicSelect = false + latestTrack = trackId + lastPlayedTrack = trackId + playingMusicLocation = null + manualMusicPlaying = true + nextMusicAt = System.currentTimeMillis() + intervalMillis + debugMessage("Allowed manual music selection $trackId.") + return true + } + + if (playImmediately(trackId)) { + latestTrack = trackId + lastPlayedTrack = trackId + playingMusicLocation = null + currentMusicLocation = getCurrentMusicLocation() + manualMusicPlaying = false + nextMusicAt = System.currentTimeMillis() + intervalMillis + debugMessage("Playing ${getMusicName(trackId)} ($trackId) immediately.") + return true + } + + latestTrack = trackId + + if (checkMusicLocationChange()) { + return false + } + + if (playingMusicLocation != null) { + return false + } + + if (jinglesOnly) { + MusicPlayer.playSong(-1) + debugMessage("Blocked music $trackId because jingles-only mode is enabled.") + return false + } + + requestPlayMusic( + reason = "request", + replacePlaying = currentMusicLocation == null && !manualMusicPlaying, + ) + return false + } + + override fun onMusicEnd(trackId: Int): Boolean { + if (trackId <= 0) { + debugMessage("Ignoring invalid music end: $trackId.") + return false + } + + playingMusicLocation = null + manualMusicPlaying = false + nextMusicAt = System.currentTimeMillis() + intervalMillis + debugMessage("Music ended: $trackId. Next music in ${formatRemaining()}.") + return true + } + + override fun onMusicSelect(slot: Int) { + manualMusicSelect = true + debugMessage("Manual music selection from slot $slot.") + } + } + + override fun Init() { + API.SetAudioHandler(handler) + log("loaded") + } + + override fun OnLogout() { + latestTrack = -1 + lastPlayedTrack = -1 + playingMusicLocation = null + currentMusicLocation = null + manualMusicSelect = false + manualMusicPlaying = false + } + + override fun OnLogin() { + if (System.currentTimeMillis() < nextMusicAt) { + MidiPlayer.playFadeOut() + MusicPlayer.groupId = -1 + debugMessage("Quiet time continues after login: ${formatRemaining()}.") + } + } + + override fun OnPluginsReloaded(): Boolean { + API.ClearAudioHandler(handler) + return false + } + + private fun requestPlayMusic(reason: String, replacePlaying: Boolean = false) { + if (!enabled || jinglesOnly) { + return + } + + if (latestTrack == -1 || API.GetMusicVolume() <= 0) { + return + } + + if (!replacePlaying && API.IsMusicPlaying() && lastPlayedTrack != -1) { + return + } + + val now = System.currentTimeMillis() + if (now < nextMusicAt) { + return + } + + val location = getCurrentMusicLocation() + if (location != null) { + playMusicForLocation(location, reason) + return + } + + MusicPlayer.playSong(latestTrack) + lastPlayedTrack = latestTrack + playingMusicLocation = null + manualMusicPlaying = false + debugMessage("Playing music ${getMusicName(latestTrack)} ($latestTrack) from $reason.") + } + + private fun playMusicForLocation(location: MusicLocation, reason: String) { + val trackToPlay = selectTrackFromLocation(location) + + MusicPlayer.playSong(trackToPlay) + lastPlayedTrack = trackToPlay + playingMusicLocation = location + manualMusicPlaying = false + debugMessage("Playing music ${getMusicName(trackToPlay)} ($trackToPlay) from $reason.") + } + + private fun selectTrackToPlay(): Int { + val location = getCurrentMusicLocation() ?: return latestTrack + if (location.tracks.isEmpty()) { + return latestTrack + } + + val choices = location.tracks.filter { it != lastPlayedTrack } + val track = if (choices.isNotEmpty()) { + choices[random.nextInt(choices.size)] + } else { + location.tracks[random.nextInt(location.tracks.size)] + } + + debugMessage("Using ${location.name} dynamic music: ${getMusicName(track)} ($track) instead of ${getMusicName(latestTrack)} ($latestTrack).") + return track + } + + private fun selectTrackFromLocation(location: MusicLocation): Int { + if (location.tracks.isEmpty()) { + return latestTrack + } + + val choices = location.tracks.filter { it != lastPlayedTrack } + return if (choices.isNotEmpty()) { + choices[random.nextInt(choices.size)] + } else { + location.tracks[random.nextInt(location.tracks.size)] + } + } + + private fun printStatus() { + val regionId = getCurrentRegionId() + val locationName = getCurrentMusicLocation()?.name ?: "none" + + log( + "enabled=$enabled jinglesOnly=$jinglesOnly playing=${API.IsMusicPlaying()} latest=$latestTrack last=$lastPlayedTrack next=${formatRemaining()} region=$regionId location=$locationName" + ) + } + + private fun formatRemaining(): String { + val remaining = nextMusicAt - System.currentTimeMillis() + if (remaining <= 0L) { + return "now" + } + + return "${remaining / 1000L}s" + } + + private fun getCurrentRegionId(): Int { + val self = PlayerList.self ?: return -1 + val worldX = self.movementQueueX[0] + Camera.originX + val worldZ = self.movementQueueZ[0] + Camera.originZ + return ((worldX shr 6) shl 8) + (worldZ shr 6) + } + + private fun getCurrentMusicLocation(): MusicLocation? { + val regionId = getCurrentRegionId() + return musicLocations.firstOrNull { regionId in it.regions } + } + + private fun checkMusicLocationChange(): Boolean { + val newLocation = getCurrentMusicLocation() + + if (newLocation == currentMusicLocation) { + return false + } + + val oldLocation = currentMusicLocation + currentMusicLocation = newLocation + + if (!enabled || manualMusicPlaying || lastPlayedTrack == -1) { + return false + } + + debugMessage( + "Music location changed from ${oldLocation?.name ?: "none"} " + + "to ${newLocation?.name ?: "none"}." + ) + + if (System.currentTimeMillis() >= nextMusicAt) { + requestPlayMusic("region change", true) + } else { + MidiPlayer.playFadeOut() + MusicPlayer.groupId = -1 + playingMusicLocation = null + } + + return true + } + + private fun playImmediately(trackId: Int): Boolean { + return trackId == NEWBIE_MELODY && + lastPlayedTrack != NEWBIE_MELODY + } + + override fun Draw(timeDelta: Long) { + checkMusicLocationChange() + } + + override fun Update() { + requestPlayMusic("timer") + updateMusicTabText() + } + + private fun updateMusicTabText() { + val component = musicTextComponent ?: return + + if (lastPlayedTrack == -1 || !API.IsMusicPlaying()) { + return + } + + val label = getMusicName(lastPlayedTrack) + if (component.text.toString() == label) { + return + } + + component.text = JagString.parse(label) + InterfaceList.redraw(component) + } + + private fun loadMusicLabels() { + if (musicLabelsLoaded) { + return + } + + val songs = API.GetDataMap(MUSIC_BUTTON_TO_SONG_ENUM) + val names = API.GetDataMap(MUSIC_BUTTON_TO_NAME_ENUM) + + var node = songs.table.head() + while (node != null) { + val buttonId = node.key.toInt() + val songId = (node as IntNode).value + val name = names.getString(buttonId).toString() + + if (name.isNotBlank()) { + musicLabels[songId] = name + } + + node = songs.table.next() + } + + musicLabelsLoaded = true + debugMessage("Loaded ${musicLabels.size} music labels.") + } + + private fun getMusicName(trackId: Int): String { + if (trackId == -1) { + return "" + } + + loadMusicLabels() + return musicLabels[trackId] ?: "Track $trackId" + } + + override fun ComponentDraw(componentIndex: Int, component: Component?, screenX: Int, screenY: Int) { + if (component?.id != MUSIC_PLAYER_TRACK_COMPONENT_ID) { + return + } + + musicTextComponent = component + updateMusicTabText() + } + + override fun ProcessCommand(commandStr: String?, args: Array?) { + if (!"::sm".equals(commandStr, ignoreCase = true) && + !"::smartaudio".equals(commandStr, ignoreCase = true)) { + return + } + + when (args?.getOrNull(0)?.toLowerCase()) { + null, "status" -> printStatus() + + "on" -> { + enabled = true + log("SmartAudio enabled") + } + + "off" -> { + enabled = false + log("SmartAudio disabled") + } + + "debug" -> { + debug = !debug + log("SmartAudio debug ${if (debug) "on" else "off"}") + } + + "jingles" -> { + jinglesOnly = !jinglesOnly + if (jinglesOnly) { + MusicPlayer.playSong(-1) + } + log("SmartAudio jingles-only ${if (jinglesOnly) "on" else "off"}") + } + + "interval" -> { + val seconds = args?.getOrNull(1)?.toLongOrNull() + if (seconds == null || seconds < 0) { + log("Usage: ::sm interval ") + return + } + + intervalMillis = seconds * 1000L + log("SmartAudio track-end interval set to ${seconds}s") + } + + "play" -> { + nextMusicAt = 0L + requestPlayMusic("command") + } + + "stop" -> { + manualMusicSelect = false + manualMusicPlaying = false + MusicPlayer.playSong(-1) + lastPlayedTrack = -1 + nextMusicAt = System.currentTimeMillis() + intervalMillis + log("music stopped; next=${formatRemaining()}") + } + + "next" -> { + manualMusicSelect = false + manualMusicPlaying = false + MusicPlayer.playSong(-1) + lastPlayedTrack = -1 + nextMusicAt = 0L + requestPlayMusic("command") + } + + "dynamic" -> { + val location = getCurrentMusicLocation() + if (location == null) { + log("No dynamic music location for region ${getCurrentRegionId()}") + return + } + + log("${location.name}: ${location.tracks.joinToString { "${getMusicName(it)} ($it)" }}") + } + + else -> log("SmartAudio commands: status, on, off, debug, jingles, interval, play, next, stop, dynamic") + } + } + + private fun log(message: String) { + println("[SmartAudio] $message") + } + + private fun debugMessage(message: String) { + if (debug) { + log(message) + } + } + + /** + * Dynamic music regions. + */ + private class MusicLocation( + val name: String, + val regions: Set, + val tracks: IntArray + ) + + //MusicLocation( + //name = "name", + //regions = setOf(), + //tracks = intArrayOf() + //), + private val musicLocations = listOf( + /** + * Misthalin (add barb village to asgarnia) + */ + MusicLocation( + name = "Lumbridge", + regions = setOf( + 12595, 12594, 12593, + 12851, 12850, 12849, + ), + tracks = intArrayOf( + 76, // Harmony + 2, // Autumn Voyage + 64, // Book of Spells + 145, // Yesteryear + 163, // Flute Salad + 327, // Dream + 62 // Newbie Melody + ) + ), + MusicLocation( + name = "Draynor Village & Wizards' Tower", + regions = setOf( + 12339, 12338, 12337 + ), + tracks = intArrayOf( + 3, // Unknown Land + 85, // Vision + 151 // Start + ) + ), + MusicLocation( + name = "Al-Kharid & Kharidian Desert", + regions = setOf( + 12591, 12590, 12589, + 12848, 12847, 12846, 12845, 12844, 12843, + 13107, 13106, 13105, 13104, 13103, 13102, 13101, 13100, 13099, + 13363, 13362, 13361, 13360, 13359, 13358, 13357, 13356, 13355, + 13617, 13616, 13615, 13614, 13613, 13612, 13611, + 13872 + ), + tracks = intArrayOf( + 122, // Shine + 123, // Arabian 2 + 47, // Duel Arena + 50, // Al Kharid + 352, // Scarab + 69, // Egypt + 79, // The Desert + 36, // Arabian + 124, // Arabian 3 + 174, // Desert Voyage + 267, // Sunburn + 465, // Desert Heat + 387, // Sphinx + 351, // Dynasty + 263, // Bandit Camp + 377, // The Golem + 383, // City of the Dead + 447, // Over to Nardah + 451, // Tune from the Dune + 505, // Pharaoh's Tomb + ) + ), + MusicLocation( + name = "Varrock & Edgeville", + regions = setOf( + 12342, + 12598, 12597, 12596, + 12854, 12853, 12852, + 13110, 13109, 13108, + 13366 + ), + tracks = intArrayOf( + 125, // Garden + 157, // Medieval + 175, // Spirit + 177, // Adventure + 56, // Doorways + 93, // Parade + 98, // Forever + 106, // Expanse + 111, // Still Night + 116, // Greatness + 496 // The Trade Parade + ) + ), + MusicLocation( + name = "Digsite", + regions = setOf( + 13365, 13364 + ), + tracks = intArrayOf( + 20, // Lullaby + 75 // Venture + ) + ), + + /** + * Morytania + */ + MusicLocation( + name = "Morytania", + regions = setOf( + 13623, 13622, 13621, + 13620, 13619, 13618, + 13879, 13878, 13877, + 13876, 13875, 13874, + 13873, 14135, 14134, + 14133, 14132, 14131, + 14130, 14129, 14391, + 14390, 14388, 14387, + 14386, 14385, 14647, + 14646 + ), + tracks = intArrayOf( + 48, // Morytania + 61, // Village + 84, // Dead Quiet + 154, // Bone Dance + 241, // Stagnant + 244, // Waterlogged + 245, // Natural + 286, // Shadowland + 288, // Deadlands + 339, // The Terrible Tower + 344, // Fenkenstrain's Refrain + 353, // Shipwrecked + 355, // The Other Side + 380, // Dance of the Undead + 501, // Distant Land + ) + ), + + /** + * Wilderness + */ + MusicLocation( + name = "Wilderness", + regions = setOf( + 11837, 11836, 11835, 11834, 11833, 11832, 11831, + 12093, 12092, 12091, 12090, 12089, 12088, 12087, + 12349, 12348, 12347, 12346, 12345, 12344, 12343, + 12605, 12604, 12603, 12602, 12601, 12600, 12599, + 12861, 12860, 12859, 12858, 12857, 12856, 12855, + 13117, 13116, 13115, 13114, 13113, 13112, 13111, + 13373, 13372, 13371, 13370, 13369, 13368, 13367 + ), + tracks = intArrayOf( + 8, // Wildwood + 10, // Moody + 13, // Mage Arena + 14, // Witching + 34, // Wonder + 37, // Deep Wildy + 42, // Wilderness2 + 43, // Wilderness3 + 52, // Serene + 56, // Doorways + 66, // Legion + 67, // Close Quarters + 96, // Inspiration + 120, // Shining + 121, // Forbidden + 159, // Gaol + 160, // Army of Darkness + 169, // Crystal Sword + 176, // Undercurrent + 179, // Underground + 182, // Dangerous + 183, // Troubled + 326, // Dark + 329, // Regal + 331, // Scape Sad + 332, // Scape Wild + 334, // Pirates of Peril + 337, // Faithless + 435, // Wilderness + 449, // Wild Isle + 475, // Wild Side + 476, // Dead Can Dance + 586, // Everlasting Fire + ) + ), + + /** + * Asgarnia + */ + MusicLocation( + name = "Rimmington & Mudskipper Point", + regions = setOf( + 11570, + 11826, 11825, 11824, + 12081 + ), + tracks = intArrayOf( + 12, // Long Way Home + 105, // Tomorrow + 138, // Emperor + 180, // Attention + 515 // Mudskipper Melody + ) + ), + MusicLocation( + name = "Falador", + regions = setOf( + 11572, 11571, + 11828, 11827, + 12084, 12083 + ), + tracks = intArrayOf( + 15, // Workshop + 49, // Wander + 72, // Fanfare + 107, // Miles Away + 127, // Nightfall + 186 // Arrival + ) + ), + MusicLocation( + name = "Barbarian/Goblin Village & Ice Mountain", + regions = setOf( + 11830, 11829, + 12086, 12085, + 12341 + ), + tracks = intArrayOf( + 54, // Scape Soft + 102, // Alone + 113, // Lightness + 141, // Barbarianism + 310, // Dwarf Theme + 313, // Goblin Village + ) + ), + + /** + * Islands/other + */ + MusicLocation( + name = "Mos Le'Harmless", + regions = setOf( + 14639, 14638, 14637, + 14895, 14894, + ), + tracks = intArrayOf( + 530, // In the Brine + 631 // Life's a Beach! + ) + ), + MusicLocation( + name = "Mos Le'Harmless (Trouble Brewing)", + regions = setOf( + 15151, 15150 + ), + tracks = intArrayOf( + 610, // Distillery Hilarity + 611, // Trouble Brewing + ) + ), + ) + +} \ No newline at end of file diff --git a/plugin-playground/src/main/kotlin/SmartAudio/plugin.properties b/plugin-playground/src/main/kotlin/SmartAudio/plugin.properties new file mode 100644 index 00000000..1d2d53b2 --- /dev/null +++ b/plugin-playground/src/main/kotlin/SmartAudio/plugin.properties @@ -0,0 +1,3 @@ +AUTHOR='Edith' +DESCRIPTION='Smart audio player' +VERSION=1.0 \ No newline at end of file